Compare commits

...
49 changed files with 8061 additions and 172 deletions
+4
View File
@@ -10,6 +10,10 @@
### Features
- feat: |Frontend Next| 新增 `frontend-next` Vite React + shadcn 命令生成组件的非 admin 邮件客户端,按参考稿 B 风格实现创建/恢复地址、收件箱、写信、地址管理、用户账号集成、设置与亮色/暗色切换
- docs: |前端/文档| 统一邮箱地址、用户账号与 Admin 权限的表述,并明确发信权限和额度按邮箱地址独立申请与管理
### Bug Fixes
### Improvements
+4
View File
@@ -10,6 +10,10 @@
### Features
- feat: |Frontend Next| Add a `frontend-next` Vite React non-admin mail client using shadcn CLI-generated components in the reference B style, covering address create/restore, inbox, compose, address management, user account integration, settings, and light/dark theme support
- docs: |Frontend/Docs| Clarify mailbox address, user account, and Admin permission terminology, including address-specific send access and balances
### Bug Fixes
### Improvements
+2 -2
View File
@@ -62,8 +62,8 @@ test.describe('Passkey Browser Flow', () => {
// Wait for user settings to load (shows user email)
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
// === Step 2: Click "User Settings" tab ===
await page.getByText('User Settings').click();
// === Step 2: Click "User Account Settings" tab ===
await page.getByText('User Account Settings').click();
// === Step 3: Create a passkey ===
await page.getByRole('button', { name: 'Create Passkey' }).click();
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
.env
.env.*
!.env.example
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temp Email Next</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "cloudflare-temp-email-next",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
"@vitejs/plugin-react": "^5.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.3.5"
},
"devDependencies": {
"@types/node": "^26.0.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3"
},
"packageManager": "pnpm@10.10.0"
}
+3210
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
const API_BASE = import.meta.env.VITE_API_BASE || ""
export type DomainOption = {
label: string
value: string
}
export type OpenSettings = {
fetched: boolean
title: string
prefix: string
addressRegex: string
minAddressLen: number
maxAddressLen: number
needAuth: boolean
enableUserCreateEmail: boolean
disableAnonymousUserCreateEmail: boolean
disableCustomAddressName: boolean
enableUserDeleteEmail: boolean
enableSendMail: boolean
enableAddressPassword: boolean
defaultDomains: string[]
randomSubdomainDomains: string[]
domains: DomainOption[]
cfTurnstileSiteKey: string
enableGlobalTurnstileCheck: boolean
}
export type AddressSettings = {
fetched: boolean
address: string
send_balance: number
auto_reply?: unknown
}
export type UserOpenSettings = {
fetched: boolean
enable: boolean
enableMailVerify: boolean
oauth2ClientIDs: { clientID: string; name: string; icon?: string }[]
}
export type UserSettings = {
fetched: boolean
user_email: string
user_id: number
is_admin: boolean
access_token: string | null
new_user_token: string | null
user_role: { domains?: string[] | null; role: string; prefix?: string | null } | null
}
export type BoundAddress = {
id: number | string
name?: string
address?: string
mail_count?: number
send_count?: number
}
export type MailItem = {
id: number | string
source?: string
address?: string
subject?: string
sender?: string
html?: string
message?: string
text?: string
raw?: string
created_at?: string
metadata?: unknown
}
export type MailListResponse = {
results: MailItem[]
count: number
}
export type SendMailPayload = {
from_name: string
to_name: string
to_mail: string
subject: string
is_html: boolean
content: string
}
export const defaultOpenSettings: OpenSettings = {
fetched: false,
title: "",
prefix: "",
addressRegex: "",
minAddressLen: 1,
maxAddressLen: 30,
needAuth: false,
enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableSendMail: false,
enableAddressPassword: false,
defaultDomains: [],
randomSubdomainDomains: [],
domains: [],
cfTurnstileSiteKey: "",
enableGlobalTurnstileCheck: false,
}
export const defaultAddressSettings: AddressSettings = {
fetched: false,
address: "",
send_balance: 0,
}
export const defaultUserOpenSettings: UserOpenSettings = {
fetched: false,
enable: false,
enableMailVerify: false,
oauth2ClientIDs: [],
}
export const defaultUserSettings: UserSettings = {
fetched: false,
user_email: "",
user_id: 0,
is_admin: false,
access_token: null,
new_user_token: null,
user_role: null,
}
const hasControlChar = (value: string) => {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 32 || code === 127) return true
}
return false
}
const safeHeaderValue = (value: string | null | undefined) => {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined
if (hasControlChar(trimmed)) return undefined
return trimmed
}
const safeBearerHeader = (jwt: string | null | undefined) => {
const safe = safeHeaderValue(jwt)
return safe ? `Bearer ${safe}` : undefined
}
const normalizeOpenSettings = (payload: Record<string, unknown>): OpenSettings => {
const domains = Array.isArray(payload.domains) ? (payload.domains as string[]) : []
const domainLabels = Array.isArray(payload.domainLabels) ? (payload.domainLabels as string[]) : []
return {
...defaultOpenSettings,
fetched: true,
title: String(payload.title || ""),
prefix: String(payload.prefix || ""),
addressRegex: String(payload.addressRegex || ""),
minAddressLen: Number(payload.minAddressLen ?? 1),
maxAddressLen: Number(payload.maxAddressLen ?? 30),
needAuth: Boolean(payload.needAuth),
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
disableCustomAddressName: Boolean(payload.disableCustomAddressName),
enableUserDeleteEmail: Boolean(payload.enableUserDeleteEmail),
enableSendMail: Boolean(payload.enableSendMail),
enableAddressPassword: Boolean(payload.enableAddressPassword),
defaultDomains: Array.isArray(payload.defaultDomains) ? (payload.defaultDomains as string[]) : [],
randomSubdomainDomains: Array.isArray(payload.randomSubdomainDomains)
? (payload.randomSubdomainDomains as string[])
: [],
domains: domains.map((domain, index) => ({
label: domainLabels[index] || domain,
value: domain,
})),
cfTurnstileSiteKey: String(payload.cfTurnstileSiteKey || ""),
enableGlobalTurnstileCheck: Boolean(payload.enableGlobalTurnstileCheck),
}
}
export async function hashPassword(password: string) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password))
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
export function parseJwtAddress(jwt: string) {
try {
const payload = JSON.parse(decodeURIComponent(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))))
return typeof payload.address === "string" ? payload.address : ""
} catch {
return ""
}
}
export function formatDate(value?: string) {
if (!value) return ""
const date = new Date(`${value} UTC`)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
export class ApiClient {
constructor(
private readonly getJwt: () => string,
private readonly getCustomAuth: () => string,
private readonly getUserJwt: () => string,
) {}
async request<T>(path: string, options: RequestInit = {}) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-lang": "en",
}
const customAuth = safeHeaderValue(this.getCustomAuth())
const userJwt = safeHeaderValue(this.getUserJwt())
const authorization = safeBearerHeader(this.getJwt())
if (customAuth) headers["x-custom-auth"] = customAuth
if (userJwt) headers["x-user-token"] = userJwt
if (authorization) headers.Authorization = authorization
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...headers,
...(options.headers as Record<string, string> | undefined),
},
})
const text = await response.text()
const data = text ? tryParseJson(text) : null
if (response.status >= 300) {
throw new Error(typeof data === "string" ? data : text || `[${response.status}] request failed`)
}
return data as T
}
async getOpenSettings() {
return normalizeOpenSettings(await this.request<Record<string, unknown>>("/open_api/settings"))
}
async getSettings() {
if (!safeHeaderValue(this.getJwt())) return { ...defaultAddressSettings, fetched: true }
const payload = await this.request<Record<string, unknown>>("/api/settings")
return {
fetched: true,
address: String(payload.address || ""),
send_balance: Number(payload.send_balance || 0),
auto_reply: payload.auto_reply,
} satisfies AddressSettings
}
async getUserOpenSettings() {
return {
...defaultUserOpenSettings,
...(await this.request<Partial<UserOpenSettings>>("/user_api/open_settings")),
fetched: true,
} satisfies UserOpenSettings
}
async getUserSettings() {
if (!safeHeaderValue(this.getUserJwt())) return { ...defaultUserSettings, fetched: true }
return {
...defaultUserSettings,
...(await this.request<Partial<UserSettings>>("/user_api/settings")),
fetched: true,
} satisfies UserSettings
}
async userLogin(email: string, password: string, cfToken: string) {
const response = await this.request<{ jwt: string }>("/user_api/login", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
cf_token: cfToken,
}),
})
return response.jwt
}
async sendUserVerifyCode(email: string, cfToken: string) {
return this.request<{ expirationTtl?: number }>("/user_api/verify_code", {
method: "POST",
body: JSON.stringify({ email, cf_token: cfToken }),
})
}
async userRegister(email: string, password: string, code: string, cfToken: string) {
await this.request("/user_api/register", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
code,
cf_token: cfToken,
}),
})
}
async listBoundAddresses() {
const response = await this.request<{ results: BoundAddress[] }>("/user_api/bind_address")
return response.results || []
}
async bindCurrentAddress() {
await this.request("/user_api/bind_address", { method: "POST" })
}
async getBoundAddressJwt(addressId: string | number) {
const response = await this.request<{ jwt: string }>(`/user_api/bind_address_jwt/${addressId}`)
return response.jwt
}
async unbindAddress(addressId: string | number) {
await this.request("/user_api/unbind_address", {
method: "POST",
body: JSON.stringify({ address_id: addressId }),
})
}
async createAddress(name: string, domain: string, cfToken: string, enableRandomSubdomain: boolean) {
return this.request<{ jwt: string; password?: string }>("/api/new_address", {
method: "POST",
body: JSON.stringify({
name,
domain,
cf_token: cfToken,
enableRandomSubdomain,
}),
})
}
async credentialLogin(credential: string, cfToken: string) {
await this.request("/open_api/credential_login", {
method: "POST",
body: JSON.stringify({ credential, cf_token: cfToken }),
})
return credential
}
async passwordLogin(email: string, password: string, cfToken: string) {
const response = await this.request<{ jwt: string }>("/api/address_login", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
cf_token: cfToken,
}),
})
return response.jwt
}
async listMails(limit: number, offset: number) {
return this.request<MailListResponse>(`/api/parsed_mails?limit=${limit}&offset=${offset}`)
}
async deleteMail(id: string | number) {
await this.request(`/api/mails/${id}`, { method: "DELETE" })
}
async sendMail(payload: SendMailPayload) {
await this.request("/api/send_mail", {
method: "POST",
body: JSON.stringify(payload),
})
}
async requestSendAccess() {
await this.request("/api/request_send_mail_access", {
method: "POST",
body: JSON.stringify({}),
})
}
async clearInbox() {
await this.request("/api/clear_inbox", { method: "DELETE" })
}
async clearSentItems() {
await this.request("/api/clear_sent_items", { method: "DELETE" })
}
async deleteAddress() {
await this.request("/api/delete_address", { method: "DELETE" })
}
}
function tryParseJson(value: string) {
try {
return JSON.parse(value)
} catch {
return value
}
}
@@ -0,0 +1,98 @@
import { useEffect, useId, useRef, useState } from "react"
import { Button } from "./ui/button"
declare global {
interface Window {
turnstile?: {
render: (
selector: string,
options: {
sitekey: string
theme: "light" | "dark"
callback: (token: string) => void
},
) => string
remove: (id: string) => void
}
}
}
type TurnstileWidgetProps = {
siteKey: string
theme: "light" | "dark"
onToken: (token: string) => void
}
let scriptPromise: Promise<void> | null = null
function loadTurnstileScript() {
if (window.turnstile) return Promise.resolve()
if (scriptPromise) return scriptPromise
scriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]")
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true })
existing.addEventListener("error", () => reject(new Error("Turnstile script failed")), { once: true })
return
}
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.dataset.turnstile = "true"
script.onload = () => resolve()
script.onerror = () => reject(new Error("Turnstile script failed"))
document.head.appendChild(script)
})
return scriptPromise
}
export function TurnstileWidget({ siteKey, theme, onToken }: TurnstileWidgetProps) {
const id = `turnstile-${useId().replace(/:/g, "")}`
const widgetId = useRef("")
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!siteKey) return undefined
let mounted = true
const renderWidget = async () => {
setFailed(false)
onToken("")
try {
await loadTurnstileScript()
if (!mounted || !window.turnstile) return
if (widgetId.current) window.turnstile.remove(widgetId.current)
widgetId.current = window.turnstile.render(`#${id}`, {
sitekey: siteKey,
theme,
callback: onToken,
})
} catch {
if (mounted) setFailed(true)
}
}
renderWidget()
return () => {
mounted = false
if (widgetId.current && window.turnstile) {
window.turnstile.remove(widgetId.current)
widgetId.current = ""
}
}
}, [id, onToken, siteKey, theme])
if (!siteKey) return null
return (
<div className="turnstile-box">
<div id={id} />
{failed && (
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Reload challenge
</Button>
)}
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,30 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,255 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,56 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+91
View File
@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App"
import "./index.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import path from "node:path"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": "http://127.0.0.1:8787",
"/open_api": "http://127.0.0.1:8787",
"/user_api": "http://127.0.0.1:8787",
"/telegram": "http://127.0.0.1:8787",
},
},
})
+23 -23
View File
@@ -5,10 +5,10 @@ export const deMessages = {
"views.Index.about": "Über",
"views.Admin.about": "Über",
"views.Header.accessHeader": "Zugangspasswort",
"views.Admin.account": "Konto",
"views.Index.accountSettings": "Kontoeinstellungen",
"views.Admin.account_settings": "Kontoeinstellungen",
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
"views.Admin.account": "E-Mail-Adressen",
"views.Index.accountSettings": "E-Mail-Adress-Einstellungen",
"views.Admin.account_settings": "E-Mail-Adress-Einstellungen",
"views.index.SimpleIndex.accountSettings": "E-Mail-Adress-Einstellungen",
"views.index.Attachment.action": "Aktion",
"views.admin.SenderAccess.action": "Aktion",
"views.user.UserSettings.actions": "Aktionen",
@@ -60,13 +60,13 @@ export const deMessages = {
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du wirklich dein Konto und alle zugehörigen E-Mails löschen?",
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen?",
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"views.admin.AccountSettings.delete_rule_confirm": "Möchtest du diese Regel wirklich löschen?",
"views.admin.UserManagement.deleteUserTip": "Möchtest du diesen Benutzer wirklich löschen?",
"views.admin.UserManagement.deleteUserTip": "Möchtest du dieses Benutzerkonto wirklich löschen?",
"views.Admin.logoutConfirmContent": "Möchtest du dich wirklich aus dem Admin-Bereich abmelden?",
"views.user.UserSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN-Organisationssperrliste",
@@ -110,7 +110,7 @@ export const deMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "Inaktive Adressen löschen, die älter als n Tage sind",
"views.admin.Maintenance.mailBoxLabel": "Posteingänge löschen, die älter als n Tage sind",
"views.admin.Maintenance.sendBoxLabel": "Postausgänge löschen, die älter als n Tage sind",
"views.admin.Maintenance.unboundAddressLabel": "Nicht verknüpfte Adressen löschen, die älter als n Tage sind",
"views.admin.Maintenance.unboundAddressLabel": "Seit n Tagen nicht verknüpfte E-Mail-Adressen löschen",
"views.admin.Maintenance.mailUnknowLabel": "E-Mails mit unbekanntem Empfänger löschen, die älter als n Tage sind",
"views.index.AccountSettings.clearInbox": "Posteingang leeren",
"views.admin.Account.clearInbox": "Posteingang leeren",
@@ -134,20 +134,20 @@ export const deMessages = {
"views.index.SimpleIndex.copyAddress": "Kopieren",
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
"views.Footer.copyright": "Urheberrecht",
"views.Admin.account_create": "Konto erstellen",
"views.Admin.account_create": "E-Mail-Adresse erstellen",
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
"views.user.UserSettings.createPasskey": "Passkey erstellen",
"views.admin.UserManagement.createUser": "Benutzer erstellen",
"views.admin.UserManagement.createUser": "Benutzerkonto erstellen",
"views.user.UserSettings.created_at": "Erstellt am",
"views.admin.Account.created_at": "Erstellt am",
"views.admin.SenderAccess.created_at": "Erstellt am",
"views.admin.UserManagement.created_at": "Erstellt am",
"views.common.Login.credentialLogin": "Mit Zugangsdaten anmelden",
"views.admin.DatabaseManager.current_db_version": "Aktuelle DB-Version",
"views.user.UserBar.currentUser": "Aktuell angemeldeter Benutzer",
"views.user.UserBar.currentUser": "Aktuelles Benutzerkonto",
"views.admin.UserManagement.roleDonotExist": "Die aktuelle Rolle existiert nicht",
"views.admin.Maintenance.customSqlCleanup": "Benutzerdefinierte SQL-Bereinigung",
"views.admin.AccountSettings.send_mail_daily_limit": "Tageslimit",
@@ -170,11 +170,11 @@ export const deMessages = {
"views.admin.UserManagement.delete": "Löschen",
"views.admin.AccountSettings.delete_rule": "Löschen",
"views.admin.Maintenance.deleteCustomSql": "Löschen",
"views.index.AccountSettings.deleteAccount": "Konto löschen",
"views.admin.Account.deleteAccount": "Konto löschen",
"views.index.AccountSettings.deleteAccount": "E-Mail-Adresse löschen",
"views.admin.Account.deleteAccount": "E-Mail-Adresse löschen",
"views.user.UserSettings.deletePasskey": "Passkey löschen",
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
"views.admin.UserManagement.deleteUser": "Benutzerkonto löschen",
"views.index.Attachment.deleteSuccess": "Erfolgreich gelöscht",
"views.admin.SenderAccess.disable": "Deaktivieren",
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
@@ -207,7 +207,7 @@ export const deMessages = {
"views.admin.Telegram.enable": "Aktivieren",
"views.admin.UserSettings.enable": "Aktivieren",
"views.admin.AiExtractSettings.enableAllowList": "Aktivieren Adressfreigabeliste",
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte Benutzer beschränken)",
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte E-Mail-Adressen beschränken)",
"views.index.AutoReply.enableAutoReply": "Automatische Antwort aktivieren",
"views.admin.Maintenance.cronTip": "Um die Cron-Bereinigung zu aktivieren, konfiguriere [crons] im Worker. Siehe Dokumentation; 0 Tage bedeutet alles löschen.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Aktivieren Tägliches Anfrage-Limit",
@@ -334,7 +334,7 @@ export const deMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Adressliste ohne Guthabenlimit",
"views.index.SimpleIndex.noMails": "Keine E-Mails gefunden",
"views.admin.RoleAddressConfig.noRolesAvailable": "In der Systemkonfiguration sind keine Rollen verfügbar",
"views.index.SendMail.requestAccessTip": "Noch kein Sendeguthaben vorhanden. Wenn der Administrator ein Standardguthaben aktiviert hat, wird es automatisch zugewiesen; andernfalls Zugriff anfordern oder den Administrator kontaktieren.",
"views.index.SendMail.requestAccessTip": "Sendezugang und Guthaben gehören zur aktuellen E-Mail-Adresse, nicht zum Benutzerkonto. Für diese Adresse Zugriff anfordern oder den Administrator kontaktieren.",
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
@@ -420,7 +420,7 @@ export const deMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "Ersetzungsvorlage",
"components.MailBox.reply": "Antworten",
"components.MailContentRenderer.reply": "Antworten",
"views.index.SendMail.requestAccess": "Zugriff anfordern",
"views.index.SendMail.requestAccess": "Zugriff für diese Adresse anfordern",
"views.user.UserLogin.resetPassword": "Zurücksetzen Passwort",
"views.admin.Account.resetPassword": "Zurücksetzen Passwort",
"views.admin.UserManagement.resetPassword": "Zurücksetzen Passwort",
@@ -556,15 +556,15 @@ export const deMessages = {
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
"views.Header.user": "Benutzer",
"views.Admin.user": "Benutzer",
"components.AddressSelect.userAddresses": "Benutzeradressen",
"views.Admin.user": "Benutzerkonten",
"components.AddressSelect.userAddresses": "Mit Benutzerkonto verknüpfte Adressen",
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
"views.admin.Statistics.userCount": "Benutzeranzahl",
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
"views.Admin.user_management": "Benutzerverwaltung",
"views.Admin.user_settings": "Benutzereinstellungen",
"views.User.user_settings": "Benutzereinstellungen",
"views.admin.UserManagement.user_email": "E-Mail des Benutzerkontos",
"views.index.AddressBar.userLogin": "Benutzerkonto-Anmeldung",
"views.Admin.user_management": "Benutzerkonten verwalten",
"views.Admin.user_settings": "Benutzerkonto-Einstellungen",
"views.User.user_settings": "Benutzerkonto-Einstellungen",
"components.AiExtractInfo.authCode": "Bestätigungscode",
"views.user.UserLogin.verifyCode": "Bestätigungscode",
"views.user.UserLogin.verifyCodeSent": "Bestätigungscode gesendet, läuft ab in {timeout} Sekunden",
+23 -23
View File
@@ -5,10 +5,10 @@ export const esMessages = {
"views.Index.about": "Acerca de",
"views.Admin.about": "Acerca de",
"views.Header.accessHeader": "Contraseña de acceso",
"views.Admin.account": "Cuenta",
"views.Index.accountSettings": "Configuración de la cuenta",
"views.Admin.account_settings": "Configuración de la cuenta",
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
"views.Admin.account": "Direcciones de correo",
"views.Index.accountSettings": "Configuración de la dirección",
"views.Admin.account_settings": "Configuración de direcciones",
"views.index.SimpleIndex.accountSettings": "Configuración de la dirección",
"views.index.Attachment.action": "Acción",
"views.admin.SenderAccess.action": "Acción",
"views.user.UserSettings.actions": "Acciones",
@@ -60,13 +60,13 @@ export const esMessages = {
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar tu cuenta y todos sus correos?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección y todos sus correos?",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"views.admin.AccountSettings.delete_rule_confirm": "¿Seguro que quieres eliminar esta regla?",
"views.admin.UserManagement.deleteUserTip": "¿Seguro que quieres eliminar este usuario?",
"views.admin.UserManagement.deleteUserTip": "¿Seguro que quieres eliminar esta cuenta de usuario?",
"views.Admin.logoutConfirmContent": "¿Seguro que quieres salir del panel de administración?",
"views.user.UserSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizaciones ASN",
@@ -110,7 +110,7 @@ export const esMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "Limpiar las direcciones inactivas de hace más de n días",
"views.admin.Maintenance.mailBoxLabel": "Limpiar la bandeja de entrada de hace más de n días",
"views.admin.Maintenance.sendBoxLabel": "Limpiar la bandeja de salida de hace más de n días",
"views.admin.Maintenance.unboundAddressLabel": "Limpiar las direcciones no vinculadas de hace más de n días",
"views.admin.Maintenance.unboundAddressLabel": "Limpiar direcciones desvinculadas desde hace n días",
"views.admin.Maintenance.mailUnknowLabel": "Limpiar los correos con destinatario desconocido de hace más de n días",
"views.index.AccountSettings.clearInbox": "Vaciar bandeja de entrada",
"views.admin.Account.clearInbox": "Vaciar bandeja de entrada",
@@ -134,20 +134,20 @@ export const esMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Error al copiar",
"views.Footer.copyright": "Derechos de autor",
"views.Admin.account_create": "Crear cuenta",
"views.Admin.account_create": "Crear dirección de correo",
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
"views.common.Login.getNewEmail": "Crear nuevo correo",
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
"views.user.UserSettings.createPasskey": "Crear passkey",
"views.admin.UserManagement.createUser": "Crear usuario",
"views.admin.UserManagement.createUser": "Crear cuenta de usuario",
"views.user.UserSettings.created_at": "Creado el",
"views.admin.Account.created_at": "Creado el",
"views.admin.SenderAccess.created_at": "Creado el",
"views.admin.UserManagement.created_at": "Creado el",
"views.common.Login.credentialLogin": "Inicio de sesión con credencial",
"views.admin.DatabaseManager.current_db_version": "Versión actual de la BD",
"views.user.UserBar.currentUser": "Usuario actual",
"views.user.UserBar.currentUser": "Cuenta de usuario actual",
"views.admin.UserManagement.roleDonotExist": "El rol actual no existe",
"views.admin.Maintenance.customSqlCleanup": "Limpieza SQL personalizada",
"views.admin.AccountSettings.send_mail_daily_limit": "Límite diario",
@@ -170,11 +170,11 @@ export const esMessages = {
"views.admin.UserManagement.delete": "Eliminar",
"views.admin.AccountSettings.delete_rule": "Eliminar",
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
"views.admin.Account.deleteAccount": "Eliminar cuenta",
"views.index.AccountSettings.deleteAccount": "Eliminar dirección de correo",
"views.admin.Account.deleteAccount": "Eliminar dirección de correo",
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
"views.admin.UserManagement.deleteUser": "Eliminar cuenta de usuario",
"views.index.Attachment.deleteSuccess": "Eliminado correctamente",
"views.admin.SenderAccess.disable": "Deshabilitar",
"views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada",
@@ -207,7 +207,7 @@ export const esMessages = {
"views.admin.Telegram.enable": "Habilitar",
"views.admin.UserSettings.enable": "Habilitar",
"views.admin.AiExtractSettings.enableAllowList": "Habilitar Lista blanca de direcciones",
"views.admin.Webhook.enableAllowList": "Habilitar lista de permitidos (restringe el acceso del webhook a usuarios específicos)",
"views.admin.Webhook.enableAllowList": "Habilitar lista de permitidos (restringe el webhook a direcciones específicas)",
"views.index.AutoReply.enableAutoReply": "Habilitar respuesta automática",
"views.admin.Maintenance.cronTip": "Para activar la limpieza por cron, configura [crons] en el worker. Consulta la documentación; 0 días significa limpiar todo.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Habilitar Límite diario de solicitudes",
@@ -334,7 +334,7 @@ export const esMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de direcciones sin límite de saldo",
"views.index.SimpleIndex.noMails": "No se encontraron correos",
"views.admin.RoleAddressConfig.noRolesAvailable": "No hay roles disponibles en la configuración del sistema",
"views.index.SendMail.requestAccessTip": "Todavía no hay saldo de envío. Si el administrador activó un saldo por defecto, se asignará automáticamente; si no, solicita acceso o contacta con el administrador.",
"views.index.SendMail.requestAccessTip": "El acceso y el saldo de envío pertenecen a la dirección actual, no a la cuenta de usuario. Solicita acceso para esta dirección o contacta con el administrador.",
"components.SendBox.emptySent": "No hay correos enviados",
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
@@ -420,7 +420,7 @@ export const esMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "Plantilla de reemplazo",
"components.MailBox.reply": "Responder",
"components.MailContentRenderer.reply": "Responder",
"views.index.SendMail.requestAccess": "Solicitar acceso",
"views.index.SendMail.requestAccess": "Solicitar acceso para esta dirección",
"views.user.UserLogin.resetPassword": "Restablecer Contraseña",
"views.admin.Account.resetPassword": "Restablecer Contraseña",
"views.admin.UserManagement.resetPassword": "Restablecer Contraseña",
@@ -556,15 +556,15 @@ export const esMessages = {
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
"views.Header.user": "Usuario",
"views.Admin.user": "Usuario",
"components.AddressSelect.userAddresses": "Direcciones del usuario",
"views.Admin.user": "Cuentas de usuario",
"components.AddressSelect.userAddresses": "Direcciones vinculadas a la cuenta",
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
"views.admin.Statistics.userCount": "Cantidad de usuarios",
"views.admin.UserManagement.user_email": "Correo del usuario",
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
"views.Admin.user_management": "Gestión de usuarios",
"views.Admin.user_settings": "Configuración de usuario",
"views.User.user_settings": "Configuración de usuario",
"views.admin.UserManagement.user_email": "Correo de la cuenta de usuario",
"views.index.AddressBar.userLogin": "Inicio de sesión de la cuenta",
"views.Admin.user_management": "Gestión de cuentas de usuario",
"views.Admin.user_settings": "Configuración de cuentas de usuario",
"views.User.user_settings": "Configuración de la cuenta de usuario",
"components.AiExtractInfo.authCode": "Código de verificación",
"views.user.UserLogin.verifyCode": "Código de verificación",
"views.user.UserLogin.verifyCodeSent": "Código de verificación enviado, expira en {timeout} segundos",
+23 -23
View File
@@ -5,10 +5,10 @@ export const jaMessages = {
"views.Index.about": "概要",
"views.Admin.about": "概要",
"views.Header.accessHeader": "アクセス用パスワード",
"views.Admin.account": "アカウント",
"views.Index.accountSettings": "アカウント設定",
"views.Admin.account_settings": "アカウント設定",
"views.index.SimpleIndex.accountSettings": "アカウント設定",
"views.Admin.account": "メールアドレス",
"views.Index.accountSettings": "メールアドレス設定",
"views.Admin.account_settings": "メールアドレス設定",
"views.index.SimpleIndex.accountSettings": "メールアドレス設定",
"views.index.Attachment.action": "操作",
"views.admin.SenderAccess.action": "操作",
"views.user.UserSettings.actions": "操作",
@@ -60,13 +60,13 @@ export const jaMessages = {
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
"views.admin.AccountSettings.delete_rule_confirm": "このルールを削除してもよろしいですか?",
"views.admin.UserManagement.deleteUserTip": "このユーザーを削除してもよろしいですか?",
"views.admin.UserManagement.deleteUserTip": "このユーザーアカウントを削除してもよろしいですか?",
"views.Admin.logoutConfirmContent": "管理画面からログアウトしてもよろしいですか?",
"views.user.UserSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN組織ブラックリスト",
@@ -110,7 +110,7 @@ export const jaMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "n 日より前の非アクティブなアドレスを削除",
"views.admin.Maintenance.mailBoxLabel": "n 日より前の受信箱を削除",
"views.admin.Maintenance.sendBoxLabel": "n 日より前の送信箱を削除",
"views.admin.Maintenance.unboundAddressLabel": "n 日より前の未紐付けアドレスを削除",
"views.admin.Maintenance.unboundAddressLabel": "紐付け解除から n 日経過したメールアドレスを削除",
"views.admin.Maintenance.mailUnknowLabel": "n 日より前の受信者不明メールを削除",
"views.index.AccountSettings.clearInbox": "受信箱を削除",
"views.admin.Account.clearInbox": "受信箱を削除",
@@ -134,20 +134,20 @@ export const jaMessages = {
"views.index.SimpleIndex.copyAddress": "コピー",
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
"views.Footer.copyright": "著作権",
"views.Admin.account_create": "アカウントを作成",
"views.Admin.account_create": "メールアドレスを作成",
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
"views.common.Login.getNewEmail": "新しいメールを作成",
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
"views.user.UserSettings.createPasskey": "パスキーを作成",
"views.admin.UserManagement.createUser": "ユーザーを作成",
"views.admin.UserManagement.createUser": "ユーザーアカウントを作成",
"views.user.UserSettings.created_at": "作成日時",
"views.admin.Account.created_at": "作成日時",
"views.admin.SenderAccess.created_at": "作成日時",
"views.admin.UserManagement.created_at": "作成日時",
"views.common.Login.credentialLogin": "資格情報でログイン",
"views.admin.DatabaseManager.current_db_version": "現在のDBバージョン",
"views.user.UserBar.currentUser": "現在のログインユーザー",
"views.user.UserBar.currentUser": "現在のユーザーアカウント",
"views.admin.UserManagement.roleDonotExist": "現在のロールは存在しません",
"views.admin.Maintenance.customSqlCleanup": "カスタムSQLクリーンアップ",
"views.admin.AccountSettings.send_mail_daily_limit": "日次上限",
@@ -170,11 +170,11 @@ export const jaMessages = {
"views.admin.UserManagement.delete": "削除",
"views.admin.AccountSettings.delete_rule": "削除",
"views.admin.Maintenance.deleteCustomSql": "削除",
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
"views.admin.Account.deleteAccount": "アカウントを削除",
"views.index.AccountSettings.deleteAccount": "メールアドレスを削除",
"views.admin.Account.deleteAccount": "メールアドレスを削除",
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
"views.admin.AccountSettings.delete_success": "削除しました",
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
"views.admin.UserManagement.deleteUser": "ユーザーアカウントを削除",
"views.index.Attachment.deleteSuccess": "正常に削除しました",
"views.admin.SenderAccess.disable": "無効化",
"views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化",
@@ -207,7 +207,7 @@ export const jaMessages = {
"views.admin.Telegram.enable": "有効化",
"views.admin.UserSettings.enable": "有効化",
"views.admin.AiExtractSettings.enableAllowList": "アドレス許可リストを有効化",
"views.admin.Webhook.enableAllowList": "許可リストを有効化 (Webhook へのアクセスを特定ユーザーに制限)",
"views.admin.Webhook.enableAllowList": "許可リストを有効化Webhook を特定のメールアドレスに制限",
"views.index.AutoReply.enableAutoReply": "自動返信を有効化",
"views.admin.Maintenance.cronTip": "cron クリーンアップを有効にするには worker の [crons] を設定してください。詳細はドキュメントを参照してください。0 日はすべて削除を意味します。",
"views.admin.IpBlacklistSettings.enable_daily_limit": "1日のリクエスト上限を有効化",
@@ -334,7 +334,7 @@ export const jaMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
"views.index.SimpleIndex.noMails": "メールが見つかりません",
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者連絡してください。",
"views.index.SendMail.requestAccessTip": "送信権限と残高はユーザーアカウントではなく現在のメールアドレスに属します。このアドレスの権限申請するか、管理者連絡してください。",
"components.SendBox.emptySent": "送信済みメールはありません",
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
"views.Admin.userOauth2Settings": "OAuth2設定",
@@ -420,7 +420,7 @@ export const jaMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "置換テンプレート",
"components.MailBox.reply": "返信",
"components.MailContentRenderer.reply": "返信",
"views.index.SendMail.requestAccess": "アクセスを申請",
"views.index.SendMail.requestAccess": "このアドレスの送信権限を申請",
"views.user.UserLogin.resetPassword": "パスワードをリセット",
"views.admin.Account.resetPassword": "パスワードをリセット",
"views.admin.UserManagement.resetPassword": "パスワードをリセット",
@@ -556,15 +556,15 @@ export const jaMessages = {
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
"views.Header.user": "ユーザー",
"views.Admin.user": "ユーザー",
"components.AddressSelect.userAddresses": "ユーザーアドレス",
"views.Admin.user": "ユーザーアカウント",
"components.AddressSelect.userAddresses": "ユーザーアカウントに紐付くアドレス",
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
"views.admin.Statistics.userCount": "ユーザー数",
"views.admin.UserManagement.user_email": "ユーザーメール",
"views.index.AddressBar.userLogin": "ユーザーログイン",
"views.Admin.user_management": "ユーザー管理",
"views.Admin.user_settings": "ユーザー設定",
"views.User.user_settings": "ユーザー設定",
"views.admin.UserManagement.user_email": "ユーザーアカウントのメール",
"views.index.AddressBar.userLogin": "ユーザーアカウントログイン",
"views.Admin.user_management": "ユーザーアカウント管理",
"views.Admin.user_settings": "ユーザーアカウント設定",
"views.User.user_settings": "ユーザーアカウント設定",
"components.AiExtractInfo.authCode": "認証コード",
"views.user.UserLogin.verifyCode": "認証コード",
"views.user.UserLogin.verifyCodeSent": "認証コードを送信しました, 有効期限 {timeout} 秒",
+23 -23
View File
@@ -5,10 +5,10 @@ export const ptBRMessages = {
"views.Index.about": "Sobre",
"views.Admin.about": "Sobre",
"views.Header.accessHeader": "Senha de acesso",
"views.Admin.account": "Conta",
"views.Index.accountSettings": "Configurações da conta",
"views.Admin.account_settings": "Configurações da conta",
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
"views.Admin.account": "Endereços de e-mail",
"views.Index.accountSettings": "Configurações do endereço",
"views.Admin.account_settings": "Configurações de endereços",
"views.index.SimpleIndex.accountSettings": "Configurações do endereço",
"views.index.Attachment.action": "Ação",
"views.admin.SenderAccess.action": "Ação",
"views.user.UserSettings.actions": "Ações",
@@ -60,13 +60,13 @@ export const ptBRMessages = {
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir sua conta e todos os e-mails dela?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço e todos os e-mails dele?",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"views.admin.AccountSettings.delete_rule_confirm": "Tem certeza de que deseja excluir esta regra?",
"views.admin.UserManagement.deleteUserTip": "Tem certeza de que deseja excluir este usuário?",
"views.admin.UserManagement.deleteUserTip": "Tem certeza de que deseja excluir esta conta de usuário?",
"views.Admin.logoutConfirmContent": "Tem certeza de que deseja sair do painel de administração?",
"views.user.UserSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizações ASN",
@@ -110,7 +110,7 @@ export const ptBRMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "Limpar os endereços inativos de mais de n dias",
"views.admin.Maintenance.mailBoxLabel": "Limpar a caixa de entrada de mais de n dias",
"views.admin.Maintenance.sendBoxLabel": "Limpar a caixa de saída de mais de n dias",
"views.admin.Maintenance.unboundAddressLabel": "Limpar os endereços desvinculados de mais de n dias",
"views.admin.Maintenance.unboundAddressLabel": "Limpar endereços desvinculados n dias",
"views.admin.Maintenance.mailUnknowLabel": "Limpar os e-mails com destinatário desconhecido de mais de n dias",
"views.index.AccountSettings.clearInbox": "Limpar caixa de entrada",
"views.admin.Account.clearInbox": "Limpar caixa de entrada",
@@ -134,20 +134,20 @@ export const ptBRMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
"views.Footer.copyright": "Direitos autorais",
"views.Admin.account_create": "Criar conta",
"views.Admin.account_create": "Criar endereço de e-mail",
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
"views.common.Login.getNewEmail": "Criar novo e-mail",
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
"views.user.UserSettings.createPasskey": "Criar passkey",
"views.admin.UserManagement.createUser": "Criar usuário",
"views.admin.UserManagement.createUser": "Criar conta de usuário",
"views.user.UserSettings.created_at": "Criado em",
"views.admin.Account.created_at": "Criado em",
"views.admin.SenderAccess.created_at": "Criado em",
"views.admin.UserManagement.created_at": "Criado em",
"views.common.Login.credentialLogin": "Login com credencial",
"views.admin.DatabaseManager.current_db_version": "Versão atual do banco",
"views.user.UserBar.currentUser": "Usuário atual",
"views.user.UserBar.currentUser": "Conta de usuário atual",
"views.admin.UserManagement.roleDonotExist": "A função atual não existe",
"views.admin.Maintenance.customSqlCleanup": "Limpeza SQL personalizada",
"views.admin.AccountSettings.send_mail_daily_limit": "Limite diário",
@@ -170,11 +170,11 @@ export const ptBRMessages = {
"views.admin.UserManagement.delete": "Excluir",
"views.admin.AccountSettings.delete_rule": "Excluir",
"views.admin.Maintenance.deleteCustomSql": "Excluir",
"views.index.AccountSettings.deleteAccount": "Excluir conta",
"views.admin.Account.deleteAccount": "Excluir conta",
"views.index.AccountSettings.deleteAccount": "Excluir endereço de e-mail",
"views.admin.Account.deleteAccount": "Excluir endereço de e-mail",
"views.user.UserSettings.deletePasskey": "Excluir passkey",
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
"views.admin.UserManagement.deleteUser": "Excluir usuário",
"views.admin.UserManagement.deleteUser": "Excluir conta de usuário",
"views.index.Attachment.deleteSuccess": "Excluído com sucesso",
"views.admin.SenderAccess.disable": "Desativar",
"views.Admin.loginViaDisabledCheck": "Verificação de senha desativada",
@@ -207,7 +207,7 @@ export const ptBRMessages = {
"views.admin.Telegram.enable": "Ativar",
"views.admin.UserSettings.enable": "Ativar",
"views.admin.AiExtractSettings.enableAllowList": "Ativar Lista branca de endereços",
"views.admin.Webhook.enableAllowList": "Ativar lista de permissão (restringir o acesso ao webhook a usuários específicos)",
"views.admin.Webhook.enableAllowList": "Ativar lista de permissão (restringir o webhook a endereços específicos)",
"views.index.AutoReply.enableAutoReply": "Ativar resposta automática",
"views.admin.Maintenance.cronTip": "Para ativar a limpeza por cron, configure [crons] no worker. Consulte a documentação; 0 dias significa limpar tudo.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Ativar limite diário de solicitações",
@@ -334,7 +334,7 @@ export const ptBRMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de endereços sem limite de saldo",
"views.index.SimpleIndex.noMails": "Nenhum e-mail encontrado",
"views.admin.RoleAddressConfig.noRolesAvailable": "Nenhuma função disponível na configuração do sistema",
"views.index.SendMail.requestAccessTip": "Ainda não há saldo de envio. Se o administrador ativou um saldo padrão, ele será atribuído automaticamente; caso contrário, solicite acesso ou fale com o administrador.",
"views.index.SendMail.requestAccessTip": "O acesso e o saldo de envio pertencem ao endereço atual, não à conta de usuário. Solicite acesso para este endereço ou fale com o administrador.",
"components.SendBox.emptySent": "Nenhum e-mail enviado",
"views.admin.RoleAddressConfig.notConfigured": "Não configurado (usar configurações globais)",
"views.Admin.userOauth2Settings": "Configurações de OAuth2",
@@ -420,7 +420,7 @@ export const ptBRMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "Modelo de substituição",
"components.MailBox.reply": "Responder",
"components.MailContentRenderer.reply": "Responder",
"views.index.SendMail.requestAccess": "Solicitar acesso",
"views.index.SendMail.requestAccess": "Solicitar acesso para este endereço",
"views.user.UserLogin.resetPassword": "Redefinir Senha",
"views.admin.Account.resetPassword": "Redefinir Senha",
"views.admin.UserManagement.resetPassword": "Redefinir Senha",
@@ -556,15 +556,15 @@ export const ptBRMessages = {
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
"views.common.Appearance.useUTCDate": "Usar data UTC",
"views.Header.user": "Usuário",
"views.Admin.user": "Usuário",
"components.AddressSelect.userAddresses": "Endereços do usuário",
"views.Admin.user": "Contas de usuário",
"components.AddressSelect.userAddresses": "Endereços vinculados à conta",
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
"views.admin.Statistics.userCount": "Quantidade de usuários",
"views.admin.UserManagement.user_email": "E-mail do usuário",
"views.index.AddressBar.userLogin": "Login do usuário",
"views.Admin.user_management": "Gerenciamento de usuários",
"views.Admin.user_settings": "Configurações do usuário",
"views.User.user_settings": "Configurações do usuário",
"views.admin.UserManagement.user_email": "E-mail da conta de usuário",
"views.index.AddressBar.userLogin": "Login da conta de usuário",
"views.Admin.user_management": "Gerenciamento de contas de usuário",
"views.Admin.user_settings": "Configurações de contas de usuário",
"views.User.user_settings": "Configurações da conta de usuário",
"components.AiExtractInfo.authCode": "Código de verificação",
"views.user.UserLogin.verifyCode": "Código de verificação",
"views.user.UserLogin.verifyCodeSent": "Código de verificação enviado, expira em {timeout} segundos",
+56 -56
View File
@@ -289,8 +289,8 @@ export const MESSAGE_REGISTRY = {
"zh": "本地地址"
},
"userAddresses": {
"en": "User Addresses",
"zh": "用户地址"
"en": "Addresses Bound to User Account",
"zh": "用户账号绑定地址"
}
},
"components.AddressCredentialModal": {
@@ -419,8 +419,8 @@ export const MESSAGE_REGISTRY = {
"zh": "关于"
},
"accountSettings": {
"en": "Account Settings",
"zh": "账户"
"en": "Mailbox Address Settings",
"zh": "邮箱地址设置"
},
"appearance": {
"en": "Appearance",
@@ -565,8 +565,8 @@ export const MESSAGE_REGISTRY = {
"zh": "Cloudflare 临时邮件"
},
"user": {
"en": "User",
"zh": "用户"
"en": "User Account",
"zh": "用户账号"
}
},
"views.user.BindAddress": {
@@ -589,16 +589,16 @@ export const MESSAGE_REGISTRY = {
"zh": "请输入 Admin 密码"
},
"account": {
"en": "Account",
"zh": "账号"
"en": "Mailbox Addresses",
"zh": "邮箱地址"
},
"account_create": {
"en": "Create Account",
"zh": "创建账号"
"en": "Create Mailbox Address",
"zh": "创建邮箱地址"
},
"account_settings": {
"en": "Account Settings",
"zh": "账号设置"
"en": "Mailbox Address Settings",
"zh": "邮箱地址设置"
},
"adminAccount": {
"en": "Admin",
@@ -705,20 +705,20 @@ export const MESSAGE_REGISTRY = {
"zh": "无收件人邮件"
},
"user": {
"en": "User",
"zh": "用户"
"en": "User Accounts",
"zh": "用户账号"
},
"userOauth2Settings": {
"en": "Oauth2 Settings",
"zh": "Oauth2 设置"
},
"user_management": {
"en": "User Management",
"zh": "用户管理"
"en": "User Account Management",
"zh": "用户账号管理"
},
"user_settings": {
"en": "User Settings",
"zh": "用户设置"
"en": "User Account Settings",
"zh": "用户账号设置"
},
"webhookSettings": {
"en": "Webhook Settings",
@@ -743,8 +743,8 @@ export const MESSAGE_REGISTRY = {
"zh": "收件箱"
},
"user_settings": {
"en": "User Settings",
"zh": "用户设置"
"en": "User Account Settings",
"zh": "用户账号设置"
}
},
"views.user.UserLogin": {
@@ -823,8 +823,8 @@ export const MESSAGE_REGISTRY = {
},
"views.user.UserBar": {
"currentUser": {
"en": "Current Login User",
"zh": "当前登录用户"
"en": "Current User Account",
"zh": "当前用户账号"
},
"fetchUserSettingsError": {
"en": "Login password is invalid or account not exist, it may be network connection issue, please try again later.",
@@ -877,8 +877,8 @@ export const MESSAGE_REGISTRY = {
"zh": "转移地址"
},
"transferAddressTip": {
"en": "Transfer address to another user will remove the address from your account and transfer it to another user. Are you sure to transfer the address?",
"zh": "转移地址到其他用户将会从你的账户中移除此地址并转移给其他用户。确定要转移地址吗?"
"en": "Transferring this address removes it from your user account and binds it to another user account. Are you sure?",
"zh": "转移后,此邮箱地址将从你的用户账号解绑,并绑定到另一个用户账号。确定要转移吗?"
},
"unbindAddress": {
"en": "Unbind Address",
@@ -915,12 +915,12 @@ export const MESSAGE_REGISTRY = {
"zh": "确认密码"
},
"deleteAccount": {
"en": "Delete Account",
"zh": "删除账户"
"en": "Delete Mailbox Address",
"zh": "删除邮箱地址"
},
"deleteAccountConfirm": {
"en": "Are you sure to delete your account and all emails for this account?",
"zh": "确定要删除你的账户和其中的所有邮件吗?"
"en": "Are you sure you want to delete this mailbox address and all of its emails?",
"zh": "确定要删除当前邮箱地址及其全部邮件吗"
},
"logout": {
"en": "Logout",
@@ -1055,12 +1055,12 @@ export const MESSAGE_REGISTRY = {
"zh": "预览"
},
"requestAccess": {
"en": "Request Access",
"zh": "申请权限"
"en": "Request Access for This Address",
"zh": "为当前地址申请发信权限"
},
"requestAccessTip": {
"en": "No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.",
"zh": "当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。"
"en": "Send access and balance belong to the current mailbox address, not the user account. This address has no send balance yet. Request access for it or contact the admin.",
"zh": "发信权限和额度属于当前邮箱地址,不属于用户账号。当前地址还没有可用额度,请为该地址申请发信权限或联系管理员。"
},
"rich text": {
"en": "Rich Text",
@@ -1105,8 +1105,8 @@ export const MESSAGE_REGISTRY = {
},
"views.index.SimpleIndex": {
"accountSettings": {
"en": "Account Settings",
"zh": "账户设置"
"en": "Mailbox Address Settings",
"zh": "邮箱地址设置"
},
"addressCopied": {
"en": "Address copied successfully",
@@ -1197,7 +1197,7 @@ export const MESSAGE_REGISTRY = {
"zh": "邮箱地址凭证"
},
"addressCredentialTip": {
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
"en": "Copy this mailbox address credential to log in to this address.",
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
},
"addressManage": {
@@ -1209,7 +1209,7 @@ export const MESSAGE_REGISTRY = {
"zh": "地址密码"
},
"fetchAddressError": {
"en": "Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.",
"en": "The mailbox address credential is invalid or the address does not exist. This may also be a network issue; please try again later.",
"zh": "邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。"
},
"linkWithAddressCredential": {
@@ -1221,8 +1221,8 @@ export const MESSAGE_REGISTRY = {
"zh": "确定"
},
"userLogin": {
"en": "User Login",
"zh": "用户登录"
"en": "User Account Login",
"zh": "用户账号登录"
}
},
"views.admin.SendBox": {
@@ -1353,7 +1353,7 @@ export const MESSAGE_REGISTRY = {
"zh": "邮箱地址凭证"
},
"addressCredentialTip": {
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
"en": "Copy this mailbox address credential to log in to this address.",
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
},
"addressQueryTip": {
@@ -1385,8 +1385,8 @@ export const MESSAGE_REGISTRY = {
"zh": "删除"
},
"deleteAccount": {
"en": "Delete Account",
"zh": "删除邮箱"
"en": "Delete Mailbox Address",
"zh": "删除邮箱地址"
},
"deleteTip": {
"en": "Are you sure to delete this email?",
@@ -1857,8 +1857,8 @@ export const MESSAGE_REGISTRY = {
"zh": "更改角色"
},
"createUser": {
"en": "Create User",
"zh": "创建用户"
"en": "Create User Account",
"zh": "创建用户账号"
},
"created_at": {
"en": "Created At",
@@ -1869,12 +1869,12 @@ export const MESSAGE_REGISTRY = {
"zh": "删除"
},
"deleteUser": {
"en": "Delete User",
"zh": "删除用户"
"en": "Delete User Account",
"zh": "删除用户账号"
},
"deleteUserTip": {
"en": "Are you sure you want to delete this user?",
"zh": "确定要删除此用户吗?"
"en": "Are you sure you want to delete this user account?",
"zh": "确定要删除此用户账号吗?"
},
"domains": {
"en": "Domains",
@@ -1921,12 +1921,12 @@ export const MESSAGE_REGISTRY = {
"zh": "成功"
},
"userAddressManagement": {
"en": "Address Management",
"zh": "地址管理"
"en": "Bound Address Management",
"zh": "绑定地址管理"
},
"user_email": {
"en": "User Email",
"zh": "用户邮箱"
"en": "User Account Email",
"zh": "用户账号邮箱"
}
},
"views.admin.Telegram": {
@@ -1989,7 +1989,7 @@ export const MESSAGE_REGISTRY = {
"zh": "邮箱地址凭证"
},
"addressCredentialTip": {
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
"en": "Copy this mailbox address credential to log in to this address.",
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
},
"addressPassword": {
@@ -2465,8 +2465,8 @@ export const MESSAGE_REGISTRY = {
"zh": "请输入天数"
},
"unboundAddressLabel": {
"en": "Cleanup the unbound address before n days",
"zh": "清理 n 天前的未绑定用户地址"
"en": "Clean up mailbox addresses unbound for n days",
"zh": "清理已解绑 n 天的邮箱地址"
}
},
"views.common.Login": {
@@ -2561,8 +2561,8 @@ export const MESSAGE_REGISTRY = {
},
"views.admin.Webhook": {
"enableAllowList": {
"en": "Enable Allow List (Restrict webhook access to specific users)",
"zh": "启用白名单 (限制 webhook 访问权限,只有白名单中的用户可以使用)"
"en": "Enable Allow List (Restrict webhook access to specific mailbox addresses)",
"zh": "启用白名单(仅允许白名单中的邮箱地址使用 Webhook)"
},
"manualInputPrompt": {
"en": "Type and press Enter to add",
@@ -152,10 +152,12 @@ wrangler secret put SMTP_CONFIG
## Send Balance Mechanism
Users need a send balance to send emails. The balance mechanism works as follows:
Send access and balance belong to a **mailbox address**, not to a user account. A user account only binds, syncs, and manages multiple mailbox addresses. Even after signing in to a user account, select a specific mailbox address and request send access for that address. Each address has its own enabled state and balance.
A mailbox address needs a send balance to send emails. The balance mechanism works as follows:
1. **Auto-initialize Default Quota**: When `DEFAULT_SEND_BALANCE > 0`, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can click "Request Access for This Address" on the current mailbox address's send page to create an address-specific request for admins to review
3. **Unlimited Sending**: The following methods can bypass balance checks:
- Add the address to the "No Limit Send Address List" in the admin console
- Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits
@@ -1,11 +1,11 @@
# Admin User Management
# Admin User Account Management
## User Management Page
## User Account Management Page
![admin-user-management](/feature/admin-user-management.png)
## User Settings
## User Account Settings
Configure user login and authentication settings here
Configure site user account login, registration, and authentication here. A user account binds and manages mailbox addresses; it is not itself a mailbox address.
![admin-user-page](/feature/admin-user-page.png)
+12 -6
View File
@@ -8,24 +8,30 @@ After deploying the frontend application, click the upper-left logo 5 times or v
You need to configure `ADMIN_PASSWORDS` in the backend or ensure the current user role is `ADMIN_USER_ROLE`, otherwise access to the console will be denied.
## Admin Passwords vs User Accounts
## Admin Passwords, User Accounts, and Mailbox Addresses
`ADMIN_PASSWORDS` is the management password for the Admin console. It is not a site user account
and does not correspond to any mailbox address. Logging in with an admin password grants access to
the console, but that login itself cannot receive mail.
Site user accounts are stored in the `users` table and use the user login flow. Whether a user can
receive mail depends on whether they created or bound a mailbox address. Creating a normal user
whose email looks like `admin@example.com` does not automatically grant admin permissions.
Site user accounts are stored in the `users` table and use the user login flow. They bind, sync, and
manage multiple mailbox addresses, but do not have an inbox, address credential, or send balance by
themselves.
Mailbox addresses are stored in the `address` table. Each address has its own inbox, address
credential, and send access. A user can receive mail only after creating or binding an address, and
send access must be requested for a specific mailbox address rather than for the user account.
Creating a normal user whose email looks like `admin@example.com` does not automatically grant admin
permissions.
If you want a user account to access the Admin console, configure `ADMIN_USER_ROLE` and assign the
same role to that user in user management.
![admin](/feature/admin.png)
## Account List Sorting
## Mailbox Address List Sorting
The Accounts tab in the admin console supports column sorting. Click the column header to toggle ascending/descending order for:
The Mailbox Addresses tab in the admin console supports column sorting. Click the column header to toggle ascending/descending order for:
- ID
- Name
@@ -93,6 +93,10 @@ curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
Requires `send_balance > 0` (check via `/api/settings`). The deployment must have a send method configured (Resend / SMTP / Cloudflare Email Routing binding).
::: tip Send access belongs to a mailbox address
`/api/request_send_mail_access` requests access for the mailbox address identified by the Address JWT in `Authorization`, not for a user account. Request access separately for each mailbox address; a User JWT cannot replace the Address JWT on this endpoint.
:::
| Task | Method | Path | Body / Returns |
| ----------------------- | ------ | ------------------------------- | ------------------------------------------- |
| Request send access | POST | `/api/request_send_mail_access` | `{}``{ status: "ok" }` |
@@ -152,10 +152,12 @@ wrangler secret put SMTP_CONFIG
## 发信余额机制
用户发送邮件需要有发信余额。余额机制如下:
发信权限和额度属于**邮箱地址**,不属于用户账号。用户账号只用于绑定、同步和管理多个邮箱地址;即使已登录用户账号,也需要先切换到具体邮箱地址,再为该地址申请发信权限。每个地址的启用状态和余额互相独立。
邮箱地址发送邮件需要有发信余额。余额机制如下:
1. **自动初始化默认额度**:当 `DEFAULT_SEND_BALANCE > 0` 时,用户打开前端发信页或第一次调用发信接口时,系统会自动为该地址初始化默认额度
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在前端界面点击「申请发信权限」按钮,创建待管理员处理的发信权限记录
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在当前邮箱地址的发信页面点击「为当前地址申请发信权限」,创建待管理员处理的地址发信权限记录
3. **无限制发送**:以下方式可以跳过余额检查:
- 在 admin 后台将地址加入「无限制发送地址列表」
- 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色
@@ -1,11 +1,11 @@
# Admin 用户相关
# Admin 用户账号管理
## 用户管理页面
## 用户账号管理页面
![admin-user-management](/feature/admin-user-management.png)
## 用户设置
## 用户账号设置
此处开启用户登录,以及验证等配置
此处配置站点用户账号的登录、注册和验证。用户账号用于绑定和管理邮箱地址,本身不是邮箱地址。
![admin-user-page](/feature/admin-user-page.png)
@@ -8,19 +8,21 @@
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`,否则不允许访问控制台。
## 管理口令用户账号的区别
## 管理口令用户账号和邮箱地址的区别
`ADMIN_PASSWORDS` 是 Admin 控制台的管理口令,不是站点用户账号,也不对应某个邮箱地址。使用管理口令登录后可以进入后台,但它本身不能收信。
站点用户账号存储在 `users` 表中,需要通过用户登录体系进入;用户是否能收信取决于是否创建或绑定了邮箱地址。即使你创建了一个邮箱为 `admin@example.com` 或用户名看起来像 `admin` 的普通用户,它也不会自动获得后台权限
站点用户账号存储在 `users` 表中,需要通过用户登录体系进入。它用于绑定、同步和管理多个邮箱地址,本身没有收件箱、地址凭证或发信额度
邮箱地址存储在 `address` 表中,每个地址有独立的收件箱、地址凭证和发信权限。用户是否能收信取决于是否创建或绑定了邮箱地址;发信权限也必须为具体邮箱地址申请,而不是为用户账号申请。即使你创建了一个邮箱为 `admin@example.com` 或用户名看起来像 `admin` 的普通用户,它也不会自动获得后台权限。
如果希望某个用户也能进入 Admin 控制台,请配置 `ADMIN_USER_ROLE`,并在用户管理中给该用户设置相同的角色。
![admin](/feature/admin.png)
## 账号列表排序
## 邮箱地址列表排序
管理后台的账号标签页支持按列排序,可点击表头对以下列进行升序/降序排列:
管理后台的邮箱地址标签页支持按列排序,可点击表头对以下列进行升序/降序排列:
- ID
- 名称
@@ -93,6 +93,10 @@ curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
需要 `send_balance > 0`(通过 `/api/settings` 查看),且部署方已配置发送方式(Resend / SMTP / Cloudflare Email Routing binding)。
::: tip 发信权限属于邮箱地址
`/api/request_send_mail_access` 申请的是 `Authorization` 中 Address JWT 对应邮箱地址的发信权限,不是用户账号的权限。不同邮箱地址需要分别申请;User JWT 不能代替 Address JWT 调用该接口。
:::
| 任务 | 方法 | 路径 | 请求体 / 返回 |
| ---------------- | ------ | ------------------------------- | ------------------------------------------ |
| 申请发信权限 | POST | `/api/request_send_mail_access` | `{}``{ status: "ok" }` |