🎉 feat: initialization
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const appleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
team_id: z.string().optional(),
|
||||
key_id: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
redirect_url: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type AppleFormData = z.infer<typeof appleSchema>;
|
||||
|
||||
export default function AppleForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "apple"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "apple",
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<AppleFormData>({
|
||||
resolver: zodResolver(appleSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
config: {
|
||||
team_id: "",
|
||||
key_id: "",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
redirect_url: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
config: {
|
||||
team_id: data.config?.team_id || "",
|
||||
key_id: data.config?.key_id || "",
|
||||
client_id: data.config?.client_id || "",
|
||||
client_secret: data.config?.client_secret || "",
|
||||
redirect_url: data.config?.redirect_url || "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: AppleFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
...values.config,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:apple" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t("apple.title", "Apple Sign-In")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"apple.description",
|
||||
"Authenticate users with Apple accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("apple.title", "Apple Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="apple-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.enableDescription",
|
||||
"When enabled, users can sign in with their Apple ID"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.team_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.teamId", "Team ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="ABCDE1FGHI"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("apple.teamIdDescription", "Apple Developer Team ID")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.key_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.keyId", "Key ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="ABC1234567"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.keyIdDescription",
|
||||
"Your private key ID from Apple Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("apple.clientId", "Service ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="com.your.app.service"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.clientIdDescription",
|
||||
"Apple Service ID, available from Apple Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("apple.clientSecret", "Private Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-20"
|
||||
onChange={field.onChange}
|
||||
placeholder={
|
||||
"-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----"
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.clientSecretDescription",
|
||||
"Private key content (.p8 file) for authenticating with Apple"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.redirect_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("apple.redirectUri", "Redirect URL")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="https://your-domain.com"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"apple.redirectUriDescription",
|
||||
"API address for redirect URL after successful Apple authentication. Do not end with /"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="apple-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { uid } from "radash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deviceSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string(),
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
show_ads: z.boolean().optional(),
|
||||
only_real_device: z.boolean().optional(),
|
||||
enable_security: z.boolean().optional(),
|
||||
security_secret: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type DeviceFormData = z.infer<typeof deviceSchema>;
|
||||
|
||||
export default function DeviceForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "device"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "device",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<DeviceFormData>({
|
||||
resolver: zodResolver(deviceSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "device",
|
||||
enabled: false,
|
||||
config: {
|
||||
show_ads: false,
|
||||
only_real_device: false,
|
||||
enable_security: false,
|
||||
security_secret: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: DeviceFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function generateSecurityKey() {
|
||||
const id = uid(32).toLowerCase();
|
||||
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
||||
form.setValue("config.security_secret", formatted);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:devices" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("device.title", "Device Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("device.description", "Authenticate users with device")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("device.title", "Device Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="device-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("device.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.enableDescription",
|
||||
"When enabled, users can sign in with device"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.show_ads"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("device.showAds", "Show Ads")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.showAdsDescription",
|
||||
"When enabled, ads will be shown"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.only_real_device"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("device.blockVirtualMachine", "Block Virtual Machine")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.blockVirtualMachineDescription",
|
||||
"Block virtual machine login, only allow real device"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_security"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("device.enableSecurity", "Enable Security")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.enableSecurityDescription",
|
||||
"When enabled, application requests must carry communication key"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.security_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("device.communicationKey", "Communication Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="e.g., 12345678-1234-1234-1234-123456789abc"
|
||||
suffix={
|
||||
<div className="flex h-9 items-center text-nowrap bg-muted px-3">
|
||||
<Icon
|
||||
className="size-4 cursor-pointer"
|
||||
icon="mdi:dice-multiple"
|
||||
onClick={generateSecurityKey}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"device.communicationKeyDescription",
|
||||
"The key used for secure communication between application and server"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="device-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { HTMLEditor } from "@workspace/ui/composed/editor/html";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
testEmailSend,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const emailSettingsSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string(),
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
enable_verify: z.boolean(),
|
||||
enable_domain_suffix: z.boolean(),
|
||||
domain_suffix_list: z.string().optional(),
|
||||
verify_email_template: z.string().optional(),
|
||||
expiration_email_template: z.string().optional(),
|
||||
maintenance_email_template: z.string().optional(),
|
||||
traffic_exceed_email_template: z.string().optional(),
|
||||
platform: z.string(),
|
||||
platform_config: z
|
||||
.object({
|
||||
host: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
ssl: z.boolean(),
|
||||
user: z.string().optional(),
|
||||
pass: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
|
||||
|
||||
export default function EmailSettingsForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState<string>();
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "email"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "email",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<EmailSettingsFormData>({
|
||||
resolver: zodResolver(emailSettingsSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "email",
|
||||
enabled: false,
|
||||
config: {
|
||||
enable_verify: false,
|
||||
enable_domain_suffix: false,
|
||||
domain_suffix_list: "",
|
||||
verify_email_template: "",
|
||||
expiration_email_template: "",
|
||||
maintenance_email_template: "",
|
||||
traffic_exceed_email_template: "",
|
||||
platform: "smtp",
|
||||
platform_config: {
|
||||
host: "",
|
||||
port: 587,
|
||||
ssl: false,
|
||||
user: "",
|
||||
pass: "",
|
||||
from: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: EmailSettingsFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...values,
|
||||
config: {
|
||||
...values.config,
|
||||
platform: "smtp",
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:email-outline" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("email.title", "Email Settings")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.description",
|
||||
"Configure email authentication and templates"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="md:!max-w-screen-lg max-w-full">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("email.title", "Email Settings")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="email-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Tabs className="space-y-2" defaultValue="basic">
|
||||
<TabsList className="flex h-full w-full flex-wrap *:flex-auto md:flex-nowrap">
|
||||
<TabsTrigger value="basic">
|
||||
{t("email.basicSettings", "Basic Settings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="smtp">
|
||||
{t("email.smtpSettings", "SMTP Settings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="verify">
|
||||
{t("email.verifyTemplate", "Verify Template")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="expiration">
|
||||
{t("email.expirationTemplate", "Expiration Template")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="maintenance">
|
||||
{t("email.maintenanceTemplate", "Maintenance Template")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="traffic">
|
||||
{t("email.trafficTemplate", "Traffic Template")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent className="space-y-2" value="basic">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.enableDescription",
|
||||
"When enabled, users can sign in with email"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_verify"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.emailVerification", "Email Verification")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.emailVerificationDescription",
|
||||
"Require email verification for new users"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_domain_suffix"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.emailSuffixWhitelist",
|
||||
"Email Suffix Whitelist"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.emailSuffixWhitelistDescription",
|
||||
"Only allow emails from whitelisted domains"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.domain_suffix_list"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.whitelistSuffixes", "Whitelist Suffixes")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
onChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.whitelistSuffixesPlaceholder",
|
||||
"gmail.com, outlook.com"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.whitelistSuffixesDescription",
|
||||
"One domain suffix per line"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="smtp">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpServerAddress", "SMTP Server Address")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpServerAddressDescription",
|
||||
"The SMTP server hostname"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpServerPort", "SMTP Server Port")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder="587"
|
||||
type="number"
|
||||
value={field.value?.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpServerPortDescription",
|
||||
"The SMTP server port (usually 25, 465, or 587)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.smtpEncryptionMethod",
|
||||
"SSL/TLS Encryption"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpEncryptionMethodDescription",
|
||||
"Enable SSL/TLS encryption for SMTP connection"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpAccount", "SMTP Account")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpAccountDescription",
|
||||
"The SMTP authentication username"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.pass"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.smtpPassword", "SMTP Password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.smtpPasswordDescription",
|
||||
"The SMTP authentication password"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.from"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email.senderAddress", "Sender Address")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"email.senderAddressDescription",
|
||||
"The email address that appears in the From field"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<FormLabel>
|
||||
{t("email.sendTestEmail", "Send Test Email")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => setTestEmail(value as string)}
|
||||
placeholder="test@example.com"
|
||||
type="email"
|
||||
value={testEmail}
|
||||
/>
|
||||
<Button
|
||||
disabled={!testEmail || isFetching}
|
||||
onClick={async () => {
|
||||
if (!testEmail) return;
|
||||
try {
|
||||
await testEmailSend({ email: testEmail });
|
||||
toast.success(
|
||||
t("email.sendSuccess", "Email sent successfully")
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
t("email.sendFailure", "Email send failed")
|
||||
);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("email.sendTestEmail", "Send Test Email")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"email.sendTestEmailDescription",
|
||||
"Send a test email to verify your SMTP configuration"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="verify">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.verify_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.verifyEmailTemplate",
|
||||
"Verify Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.Type}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.type.description",
|
||||
"Email type (1: Register, 2: Reset Password)"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-6 text-orange-600 dark:text-orange-400">
|
||||
💡{" "}
|
||||
{t(
|
||||
"email.templateVariables.type.conditionalSyntax",
|
||||
"Use conditional syntax to display different content"
|
||||
)}
|
||||
<br />
|
||||
<code className="rounded bg-orange-50 px-1 text-xs dark:bg-orange-900/20">
|
||||
{"{{if eq .Type 1}}...{{else}}...{{end}}"}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.Expire}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.expire.description",
|
||||
"Code expiration time"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.Code}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.code.description",
|
||||
"Verification code"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="expiration">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.expiration_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.expirationEmailTemplate",
|
||||
"Expiration Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.ExpireDate}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.expireDate.description",
|
||||
"Subscription expiration date"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="maintenance">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.maintenance_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.maintenanceEmailTemplate",
|
||||
"Maintenance Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.MaintenanceDate}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.maintenanceDate.description",
|
||||
"Maintenance date"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.MaintenanceTime}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.maintenanceTime.description",
|
||||
"Maintenance time"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="space-y-2" value="traffic">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.traffic_exceed_email_template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"email.trafficExceedEmailTemplate",
|
||||
"Traffic Exceed Email Template"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HTMLEditor
|
||||
onBlur={field.onChange}
|
||||
placeholder={t(
|
||||
"email.inputPlaceholder",
|
||||
"Please enter"
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<p className="font-medium text-muted-foreground text-sm">
|
||||
{t(
|
||||
"email.templateVariables.title",
|
||||
"Template Variables"
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 text-muted-foreground text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteLogo}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteLogo.description",
|
||||
"Site logo URL"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||
{"{{.SiteName}}"}
|
||||
</code>
|
||||
<span>
|
||||
{t(
|
||||
"email.templateVariables.siteName.description",
|
||||
"Site name"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="email-settings-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const facebookSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
type FacebookFormData = z.infer<typeof facebookSchema>;
|
||||
|
||||
export default function FacebookForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "facebook"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "facebook",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<FacebookFormData>({
|
||||
resolver: zodResolver(facebookSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
client_id: data.config?.client_id || "",
|
||||
client_secret: data.config?.client_secret || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: FacebookFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
client_id: values.client_id,
|
||||
client_secret: values.client_secret,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:facebook" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("facebook.title", "Facebook Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"facebook.description",
|
||||
"Authenticate users with Facebook accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("facebook.title", "Facebook Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="facebook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("facebook.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"facebook.enableDescription",
|
||||
"When enabled, users can sign in with their Facebook account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("facebook.clientId", "App ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="1234567890123456"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"facebook.clientIdDescription",
|
||||
"Facebook App ID, available from Facebook Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("facebook.clientSecret", "App Secret")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="1234567890abcdef1234567890abcdef"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"facebook.clientSecretDescription",
|
||||
"Facebook App Secret, available from Facebook Developer Portal"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="facebook-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const githubSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
type GithubFormData = z.infer<typeof githubSchema>;
|
||||
|
||||
export default function GithubForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "github"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "github",
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<GithubFormData>({
|
||||
resolver: zodResolver(githubSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
client_id: data.config?.client_id || "",
|
||||
client_secret: data.config?.client_secret || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: GithubFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
client_id: values.client_id,
|
||||
client_secret: values.client_secret,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:github" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("github.title", "GitHub Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"github.description",
|
||||
"Authenticate users with GitHub accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("github.title", "GitHub Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="github-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("github.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"github.enableDescription",
|
||||
"When enabled, users can sign in with their GitHub account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("github.clientId", "Client ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="e.g., Iv1.1234567890abcdef"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"github.clientIdDescription",
|
||||
"GitHub OAuth App Client ID, available from GitHub Developer Settings"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("github.clientSecret", "Client Secret")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="e.g., 1234567890abcdef1234567890abcdef12345678"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"github.clientSecretDescription",
|
||||
"GitHub OAuth App Client Secret, available from GitHub Developer Settings"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="github-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const googleSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string().default("google").optional(),
|
||||
enabled: z.boolean().default(false).optional(),
|
||||
config: z
|
||||
.object({
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type GoogleFormData = z.infer<typeof googleSchema>;
|
||||
|
||||
export default function GoogleForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "google"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "google",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<GoogleFormData>({
|
||||
resolver: zodResolver(googleSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "google",
|
||||
enabled: false,
|
||||
config: {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: GoogleFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:google" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("google.title", "Google Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"google.description",
|
||||
"Authenticate users with Google accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("google.title", "Google Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="google-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("google.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"google.enableDescription",
|
||||
"When enabled, users can sign in with their Google account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("google.clientId", "Client ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="123456789-abc123def456.apps.googleusercontent.com"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"google.clientIdDescription",
|
||||
"Google OAuth Client ID, available from Google Cloud Console"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.client_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("google.clientSecret", "Client Secret")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"google.clientSecretDescription",
|
||||
"Google OAuth Client Secret, available from Google Cloud Console"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="google-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import TagInput from "@workspace/ui/composed/tag-input";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
getSmsPlatform,
|
||||
testSmsSend,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const phoneSettingsSchema = z.object({
|
||||
id: z.number(),
|
||||
method: z.string(),
|
||||
enabled: z.boolean(),
|
||||
config: z
|
||||
.object({
|
||||
enable_whitelist: z.boolean().optional(),
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
platform: z.string().optional(),
|
||||
platform_config: z
|
||||
.object({
|
||||
access: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
template_code: z.string().optional(),
|
||||
sign_name: z.string().optional(),
|
||||
phone_number: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type PhoneSettingsFormData = z.infer<typeof phoneSettingsSchema>;
|
||||
|
||||
export default function PhoneSettingsForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testParams, setTestParams] = useState<API.TestSmsSendRequest>({
|
||||
telephone: "",
|
||||
area_code: "1",
|
||||
});
|
||||
|
||||
const { data, refetch, isFetching } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "mobile"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "mobile",
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: platforms } = useQuery({
|
||||
queryKey: ["getSmsPlatform"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSmsPlatform();
|
||||
return data.data?.list;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<PhoneSettingsFormData>({
|
||||
resolver: zodResolver(phoneSettingsSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
method: "mobile",
|
||||
enabled: false,
|
||||
config: {
|
||||
enable_whitelist: false,
|
||||
whitelist: [],
|
||||
platform: "",
|
||||
platform_config: {
|
||||
access: "",
|
||||
endpoint: "",
|
||||
secret: "",
|
||||
template_code: "code",
|
||||
sign_name: "",
|
||||
phone_number: "",
|
||||
template: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPlatform = platforms?.find(
|
||||
(platform) => platform.platform === form.watch("config.platform")
|
||||
);
|
||||
const { platform_url, platform_field_description: platformConfig } =
|
||||
selectedPlatform ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: PhoneSettingsFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon
|
||||
className="h-5 w-5 text-primary"
|
||||
icon="mdi:phone-settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t("phone.title", "SMS Settings")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("phone.description", "Configure SMS authentication")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("phone.title", "SMS Settings")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="phone-settings-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("phone.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
disabled={isFetching}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.enableTip",
|
||||
"When enabled, users can sign in with their phone number"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enable_whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.whitelistValidation", "Whitelist Validation")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.whitelistValidationTip",
|
||||
"Only allow phone numbers with whitelisted area codes"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.whitelistAreaCode", "Whitelist Area Codes")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
onChange={field.onChange}
|
||||
placeholder="1, 852, 886, 888"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.whitelistAreaCodeTip",
|
||||
"Enter area codes separated by commas"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("phone.platform", "SMS Platform")}</FormLabel>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms?.map((item) => (
|
||||
<SelectItem
|
||||
key={item.platform}
|
||||
value={item.platform}
|
||||
>
|
||||
{item.platform}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{platform_url && (
|
||||
<Button asChild size="sm">
|
||||
<Link target="_blank" to={platform_url}>
|
||||
{t("phone.applyPlatform", "Apply")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("phone.platformTip", "Select SMS service provider")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.access"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.accessLabel", "Access Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.access,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.access,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{platformConfig?.endpoint && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.endpointLabel", "Endpoint")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.endpoint,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.endpoint,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.secretLabel", "Secret Key")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.secret,
|
||||
}
|
||||
)}
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.secret,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{platformConfig?.template_code && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.template_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.templateCodeLabel", "Template Code")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.template_code,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.template_code,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.sign_name && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.sign_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.signNameLabel", "Sign Name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.sign_name,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.sign_name,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.phone_number && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.phone_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("phone.phoneNumberLabel", "Phone Number")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
disabled={isFetching}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.platformConfigTip",
|
||||
"Please enter {{key}}",
|
||||
{
|
||||
key: platformConfig?.phone_number,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("phone.platformConfigTip", "Please enter {{key}}", {
|
||||
key: platformConfig?.phone_number,
|
||||
})}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{platformConfig?.code_variable && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.platform_config.template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("phone.template", "Template")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
disabled={isFetching}
|
||||
onChange={field.onChange}
|
||||
placeholder={t(
|
||||
"phone.placeholders.template",
|
||||
"Use {{code}} for verification code",
|
||||
{
|
||||
code: platformConfig?.code_variable,
|
||||
}
|
||||
)}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"phone.templateTip",
|
||||
"Use {{code}} variable for the verification code",
|
||||
{
|
||||
code: platformConfig?.code_variable,
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div>
|
||||
<FormLabel>{t("phone.testSms", "Test SMS")}</FormLabel>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
{t(
|
||||
"phone.testSmsTip",
|
||||
"Send a test SMS to verify configuration"
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AreaCodeSelect
|
||||
onChange={(value) => {
|
||||
if (value.phone) {
|
||||
setTestParams((prev) => ({
|
||||
...prev,
|
||||
area_code: value.phone!,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={testParams.area_code}
|
||||
/>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
setTestParams((prev) => ({
|
||||
...prev,
|
||||
telephone: value as string,
|
||||
}));
|
||||
}}
|
||||
placeholder={t("phone.testSmsPhone", "Phone number")}
|
||||
value={testParams.telephone}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
!(testParams.telephone && testParams.area_code) ||
|
||||
isFetching
|
||||
}
|
||||
onClick={async () => {
|
||||
if (
|
||||
isFetching ||
|
||||
!testParams.telephone ||
|
||||
!testParams.area_code
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await testSmsSend(testParams);
|
||||
toast.success(
|
||||
t("phone.sendSuccess", "SMS sent successfully")
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("phone.sendFailed", "SMS send failed"));
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("phone.testSms", "Test SMS")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="phone-settings-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import {
|
||||
getAuthMethodConfig,
|
||||
updateAuthMethodConfig,
|
||||
} from "@workspace/ui/services/admin/authMethod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const telegramSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
bot: z.string().optional(),
|
||||
bot_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type TelegramFormData = z.infer<typeof telegramSchema>;
|
||||
|
||||
export default function TelegramForm() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["getAuthMethodConfig", "telegram"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthMethodConfig({
|
||||
method: "telegram",
|
||||
});
|
||||
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<TelegramFormData>({
|
||||
resolver: zodResolver(telegramSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
bot: "",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enabled: data.enabled,
|
||||
bot: data.config?.bot || "",
|
||||
bot_token: data.config?.bot_token || "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: TelegramFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateAuthMethodConfig({
|
||||
...data,
|
||||
enabled: values.enabled,
|
||||
config: {
|
||||
...data?.config,
|
||||
bot: values.bot,
|
||||
bot_token: values.bot_token,
|
||||
},
|
||||
} as API.UpdateAuthMethodConfigRequest);
|
||||
toast.success(t("common.saveSuccess", "Saved successfully"));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error(t("common.saveFailed", "Save failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" icon="mdi:telegram" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{t("telegram.title", "Telegram Sign-In")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"telegram.description",
|
||||
"Authenticate users with Telegram accounts"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("telegram.title", "Telegram Sign-In")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-2 pt-4"
|
||||
id="telegram-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("telegram.enable", "Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
className="!mt-0 float-end"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"telegram.enableDescription",
|
||||
"When enabled, users can sign in with their Telegram account"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("telegram.clientId", "Bot ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="6123456789"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"telegram.clientIdDescription",
|
||||
"Telegram Bot ID, available from @BotFather"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("telegram.clientSecret", "Bot Token")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={field.onChange}
|
||||
placeholder="6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
type="password"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"telegram.clientSecretDescription",
|
||||
"Telegram Bot Token, available from @BotFather"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} form="telegram-form" type="submit">
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@workspace/ui/components/table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AppleForm from "./forms/apple-form";
|
||||
import DeviceForm from "./forms/device-form";
|
||||
import EmailSettingsForm from "./forms/email-settings-form";
|
||||
import FacebookForm from "./forms/facebook-form";
|
||||
import GithubForm from "./forms/github-form";
|
||||
import GoogleForm from "./forms/google-form";
|
||||
import PhoneSettingsForm from "./forms/phone-settings-form";
|
||||
import TelegramForm from "./forms/telegram-form";
|
||||
|
||||
export default function AuthControl() {
|
||||
const { t } = useTranslation("auth-control");
|
||||
|
||||
const formSections = [
|
||||
{
|
||||
title: t("communicationMethods", "Communication Methods"),
|
||||
forms: [
|
||||
{ component: EmailSettingsForm },
|
||||
{ component: PhoneSettingsForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("socialAuthMethods", "Social Authentication Methods"),
|
||||
forms: [
|
||||
{ component: AppleForm },
|
||||
{ component: GoogleForm },
|
||||
{ component: FacebookForm },
|
||||
{ component: GithubForm },
|
||||
{ component: TelegramForm },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("deviceAuthMethods", "Device Authentication Methods"),
|
||||
forms: [{ component: DeviceForm }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{formSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<h2 className="mb-4 font-semibold text-lg">{section.title}</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{section.forms.map((form, formIndex) => {
|
||||
const FormComponent = form.component;
|
||||
return (
|
||||
<TableRow key={formIndex}>
|
||||
<TableCell>
|
||||
<FormComponent />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user