@nocobase/portal-template-default 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/scripts/build-html.mjs +59 -2
  3. package/src/extensions/nocobase-auth-cas/README.md +6 -0
  4. package/src/extensions/nocobase-auth-cas/cas-sign-in-button.tsx +41 -0
  5. package/src/extensions/nocobase-auth-cas/demo.tsx +20 -0
  6. package/src/extensions/nocobase-auth-cas/extension.tsx +48 -0
  7. package/src/extensions/nocobase-auth-cas/index.ts +1 -0
  8. package/src/extensions/nocobase-auth-cas/use-cas-sign-in.ts +31 -0
  9. package/src/extensions/nocobase-auth-dingtalk/README.md +7 -0
  10. package/src/extensions/nocobase-auth-dingtalk/auto-login-provider.tsx +185 -0
  11. package/src/extensions/nocobase-auth-dingtalk/demo.tsx +23 -0
  12. package/src/extensions/nocobase-auth-dingtalk/dingtalk-sign-in-button.tsx +45 -0
  13. package/src/extensions/nocobase-auth-dingtalk/extension.tsx +53 -0
  14. package/src/extensions/nocobase-auth-dingtalk/index.ts +4 -0
  15. package/src/extensions/nocobase-auth-dingtalk/use-dingtalk-sign-in.ts +66 -0
  16. package/src/extensions/nocobase-auth-ldap/README.md +6 -0
  17. package/src/extensions/nocobase-auth-ldap/demo.tsx +21 -0
  18. package/src/extensions/nocobase-auth-ldap/extension.tsx +48 -0
  19. package/src/extensions/nocobase-auth-ldap/index.ts +1 -0
  20. package/src/extensions/nocobase-auth-ldap/ldap-sign-in-form.tsx +69 -0
  21. package/src/extensions/nocobase-auth-ldap/use-ldap-sign-in.ts +5 -0
  22. package/src/extensions/nocobase-auth-oidc/README.md +11 -0
  23. package/src/extensions/nocobase-auth-oidc/auto-redirect-provider.tsx +13 -0
  24. package/src/extensions/nocobase-auth-oidc/demo.tsx +20 -0
  25. package/src/extensions/nocobase-auth-oidc/extension.tsx +51 -0
  26. package/src/extensions/nocobase-auth-oidc/index.ts +1 -0
  27. package/src/extensions/nocobase-auth-oidc/oidc-sign-in-button.tsx +44 -0
  28. package/src/extensions/nocobase-auth-oidc/use-oidc-sign-in.ts +60 -0
  29. package/src/extensions/nocobase-auth-saml/README.md +6 -0
  30. package/src/extensions/nocobase-auth-saml/auto-redirect-provider.tsx +13 -0
  31. package/src/extensions/nocobase-auth-saml/demo.tsx +20 -0
  32. package/src/extensions/nocobase-auth-saml/extension.tsx +51 -0
  33. package/src/extensions/nocobase-auth-saml/index.ts +1 -0
  34. package/src/extensions/nocobase-auth-saml/saml-sign-in-button.tsx +43 -0
  35. package/src/extensions/nocobase-auth-saml/use-saml-sign-in.ts +53 -0
  36. package/src/extensions/nocobase-auth-sms/README.md +7 -0
  37. package/src/extensions/nocobase-auth-sms/demo.tsx +25 -0
  38. package/src/extensions/nocobase-auth-sms/extension.tsx +48 -0
  39. package/src/extensions/nocobase-auth-sms/index.ts +1 -0
  40. package/src/extensions/nocobase-auth-sms/sms-sign-in-form.tsx +107 -0
  41. package/src/extensions/nocobase-auth-sms/use-sms-sign-in.ts +88 -0
  42. package/src/extensions/nocobase-auth-wecom/README.md +6 -0
  43. package/src/extensions/nocobase-auth-wecom/auto-login-provider.tsx +21 -0
  44. package/src/extensions/nocobase-auth-wecom/demo.tsx +23 -0
  45. package/src/extensions/nocobase-auth-wecom/extension.tsx +51 -0
  46. package/src/extensions/nocobase-auth-wecom/index.ts +1 -0
  47. package/src/extensions/nocobase-auth-wecom/use-wecom-sign-in.ts +64 -0
  48. package/src/extensions/nocobase-auth-wecom/wecom-sign-in-button.tsx +70 -0
  49. package/src/extensions/nocobase-i18n/README.md +44 -0
  50. package/src/extensions/nocobase-i18n/components/index.ts +1 -0
  51. package/src/extensions/nocobase-i18n/components/language-switcher.tsx +138 -0
  52. package/src/extensions/nocobase-i18n/demo/index.tsx +209 -0
  53. package/src/extensions/nocobase-i18n/demo/prompt-generator.tsx +132 -0
  54. package/src/extensions/nocobase-i18n/extension.tsx +48 -0
  55. package/src/extensions/nocobase-i18n/index.ts +7 -0
  56. package/src/extensions/nocobase-i18n/locales/en-US.ts +39 -0
  57. package/src/extensions/nocobase-i18n/locales/index.ts +8 -0
  58. package/src/extensions/nocobase-i18n/locales/zh-CN.ts +35 -0
  59. package/src/extensions/nocobase-i18n/provider.tsx +44 -0
  60. package/src/extensions/nocobase-i18n/server-resources.ts +166 -0
  61. package/src/extensions/nocobase-i18n/tests/i18n-regression.mjs +51 -0
@@ -0,0 +1,107 @@
1
+ import { useState } from "react";
2
+
3
+ import type { AuthenticatorComponentProps } from "@/components/auth/types";
4
+ import {
5
+ Alert,
6
+ AlertDescription,
7
+ AlertTitle,
8
+ } from "@/components/ui/alert";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Label } from "@/components/ui/label";
12
+
13
+ import { useSmsSignIn } from "./use-sms-sign-in";
14
+
15
+ export default function SmsSignInForm({
16
+ authenticator,
17
+ onSendCode,
18
+ onSignIn,
19
+ }: AuthenticatorComponentProps & {
20
+ onSendCode?: (phone: string) => void;
21
+ onSignIn?: (values: { phone: string; code: string }) => void;
22
+ }) {
23
+ const [phone, setPhone] = useState("");
24
+ const [code, setCode] = useState("");
25
+ const sms = useSmsSignIn(authenticator);
26
+ const autoSignup = authenticator.options?.autoSignup === true;
27
+
28
+ return (
29
+ <form
30
+ className="space-y-5"
31
+ onSubmit={(event) => {
32
+ event.preventDefault();
33
+ if (onSignIn) {
34
+ onSignIn({ phone, code });
35
+ return;
36
+ }
37
+ void sms.signIn(phone, code);
38
+ }}
39
+ >
40
+ {sms.error && (
41
+ <Alert variant="destructive">
42
+ <AlertTitle>SMS sign-in failed</AlertTitle>
43
+ <AlertDescription>{sms.error.message}</AlertDescription>
44
+ </Alert>
45
+ )}
46
+ <div className="space-y-2">
47
+ <Label htmlFor={`${authenticator.name}-phone`}>Phone</Label>
48
+ <Input
49
+ id={`${authenticator.name}-phone`}
50
+ type="tel"
51
+ value={phone}
52
+ onChange={(event) => setPhone(event.target.value)}
53
+ autoComplete="tel"
54
+ required
55
+ />
56
+ </div>
57
+ <div className="space-y-2">
58
+ <Label htmlFor={`${authenticator.name}-code`}>
59
+ Verification code
60
+ </Label>
61
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
62
+ <Input
63
+ id={`${authenticator.name}-code`}
64
+ inputMode="numeric"
65
+ value={code}
66
+ onChange={(event) => setCode(event.target.value)}
67
+ autoComplete="one-time-code"
68
+ required
69
+ />
70
+ <Button
71
+ type="button"
72
+ variant="outline"
73
+ disabled={
74
+ (!onSendCode && (sms.isSendingCode || sms.retryAfter > 0)) ||
75
+ !phone
76
+ }
77
+ onClick={() => {
78
+ if (onSendCode) {
79
+ onSendCode(phone);
80
+ return;
81
+ }
82
+ void sms.sendCode(phone).catch(() => undefined);
83
+ }}
84
+ >
85
+ {!onSendCode && sms.isSendingCode
86
+ ? "Sending…"
87
+ : !onSendCode && sms.retryAfter > 0
88
+ ? `Retry in ${sms.retryAfter}s`
89
+ : "Send code"}
90
+ </Button>
91
+ </div>
92
+ </div>
93
+ <Button
94
+ type="submit"
95
+ className="w-full"
96
+ disabled={!onSignIn && sms.isSigningIn}
97
+ >
98
+ {!onSignIn && sms.isSigningIn ? "Signing in…" : "Sign in"}
99
+ </Button>
100
+ {autoSignup && (
101
+ <p className="text-xs leading-5 text-muted-foreground">
102
+ An account will be created automatically if this phone number is new.
103
+ </p>
104
+ )}
105
+ </form>
106
+ );
107
+ }
@@ -0,0 +1,88 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+
3
+ import { useAuthenticatorSignIn } from "@/components/auth";
4
+ import type { Authenticator } from "@/components/auth/types";
5
+ import { nocobaseClient } from "@/lib/nocobase/client";
6
+
7
+ type SmsCodeResponse = {
8
+ expiresAt?: string;
9
+ };
10
+
11
+ export function useSmsSignIn(authenticator: Authenticator) {
12
+ const auth = useAuthenticatorSignIn(authenticator.name);
13
+ const [isSendingCode, setIsSendingCode] = useState(false);
14
+ const [sendError, setSendError] = useState<Error>();
15
+ const [expiresAt, setExpiresAt] = useState<number>();
16
+ const [now, setNow] = useState(Date.now());
17
+ const verifier =
18
+ typeof authenticator.options?.verifier === "string"
19
+ ? authenticator.options.verifier
20
+ : "";
21
+
22
+ useEffect(() => {
23
+ if (!expiresAt || expiresAt <= Date.now()) return;
24
+ const timer = window.setInterval(() => setNow(Date.now()), 1_000);
25
+ return () => window.clearInterval(timer);
26
+ }, [expiresAt]);
27
+
28
+ const retryAfter = useMemo(
29
+ () =>
30
+ expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1_000)) : 0,
31
+ [expiresAt, now]
32
+ );
33
+
34
+ const sendCode = useCallback(
35
+ async (phone: string) => {
36
+ if (!phone) throw new Error("Enter a phone number first.");
37
+ if (!verifier) {
38
+ throw new Error("The SMS verifier is not configured.");
39
+ }
40
+ if (retryAfter > 0) return;
41
+
42
+ setSendError(undefined);
43
+ setIsSendingCode(true);
44
+ try {
45
+ const result = await nocobaseClient.action<SmsCodeResponse>(
46
+ "smsOTP",
47
+ "publicCreate",
48
+ {
49
+ method: "POST",
50
+ authenticator: null,
51
+ includeRole: false,
52
+ withAclMeta: false,
53
+ body: {
54
+ action: "auth:signIn",
55
+ verifier,
56
+ uuid: phone,
57
+ },
58
+ }
59
+ );
60
+ const nextExpiry = result?.expiresAt
61
+ ? Date.parse(result.expiresAt)
62
+ : Date.now() + 60_000;
63
+ setNow(Date.now());
64
+ setExpiresAt(nextExpiry);
65
+ } catch (cause) {
66
+ const error =
67
+ cause instanceof Error
68
+ ? cause
69
+ : new Error("Unable to send the verification code.");
70
+ setSendError(error);
71
+ throw error;
72
+ } finally {
73
+ setIsSendingCode(false);
74
+ }
75
+ },
76
+ [retryAfter, verifier]
77
+ );
78
+
79
+ return {
80
+ sendCode,
81
+ signIn: (phone: string, code: string) =>
82
+ auth.signIn({ uuid: phone, code }),
83
+ isSendingCode,
84
+ isSigningIn: auth.isPending,
85
+ retryAfter,
86
+ error: sendError ?? auth.error,
87
+ };
88
+ }
@@ -0,0 +1,6 @@
1
+ # NocoBase WeCom authentication
2
+
3
+ Adds the WeCom sign-in button, callback errors, WeCom-browser automatic login,
4
+ and `useWecomSignIn` for custom UI.
5
+
6
+ Installing the Registry also adds a WeCom component Demo under Authentication.
@@ -0,0 +1,21 @@
1
+ import type { PropsWithChildren } from "react";
2
+
3
+ import { AuthAutoRedirectProvider } from "@/components/auth";
4
+
5
+ import { isWecomBrowser } from "./use-wecom-sign-in";
6
+
7
+ export default function WecomAutoLoginProvider({
8
+ children,
9
+ }: PropsWithChildren) {
10
+ const insideWecom = isWecomBrowser();
11
+ return (
12
+ <AuthAutoRedirectProvider
13
+ resource="wecom"
14
+ action="checkLogin"
15
+ enabled={insideWecom}
16
+ query={{ isWeComBrowser: insideWecom }}
17
+ >
18
+ {children}
19
+ </AuthAutoRedirectProvider>
20
+ );
21
+ }
@@ -0,0 +1,23 @@
1
+ import { AuthMethodDemo } from "@/components/auth/demo";
2
+ import WecomSignInButton from "./wecom-sign-in-button";
3
+
4
+ const authenticator = {
5
+ name: "company-wecom",
6
+ authType: "wecom",
7
+ title: "Sign in via WeCom",
8
+ options: {
9
+ btnTooltip: "Use the account associated with your company directory.",
10
+ },
11
+ };
12
+
13
+ export function WecomAuthDemoPage() {
14
+ return (
15
+ <AuthMethodDemo
16
+ authType="wecom"
17
+ methodName="WeCom"
18
+ description="Provide a WeCom sign-in button and support automatic login when the application is opened inside WeCom."
19
+ >
20
+ <WecomSignInButton authenticator={authenticator} onSignIn={() => {}} />
21
+ </AuthMethodDemo>
22
+ );
23
+ }
@@ -0,0 +1,51 @@
1
+ import { lazy } from "react";
2
+ import { Building2 } from "lucide-react";
3
+ import { Route } from "react-router";
4
+
5
+ import type { AppExtension } from "@/app/extension";
6
+ import { AuthDemoRoute } from "@/components/auth/demo";
7
+
8
+ const WecomSignInButton = lazy(() => import("./wecom-sign-in-button"));
9
+ const WecomAutoLoginProvider = lazy(() => import("./auto-login-provider"));
10
+ const WecomAuthDemoPage = lazy(() =>
11
+ import("./demo").then((module) => ({ default: module.WecomAuthDemoPage }))
12
+ );
13
+
14
+ const wecomAuthExtension: AppExtension = {
15
+ id: "nocobase-auth-wecom",
16
+ AuthRuntimeProvider: WecomAutoLoginProvider,
17
+ authRuntimePriority: 10,
18
+ dev: {
19
+ resources: [
20
+ {
21
+ name: "auth-wecom-demo",
22
+ list: "auth/wecom",
23
+ meta: {
24
+ parent: "auth-components",
25
+ label: "WeCom",
26
+ icon: <Building2 />,
27
+ acl: { type: "authenticated" },
28
+ },
29
+ },
30
+ ],
31
+ routes: (
32
+ <Route
33
+ path="auth/wecom"
34
+ element={
35
+ <AuthDemoRoute>
36
+ <WecomAuthDemoPage />
37
+ </AuthDemoRoute>
38
+ }
39
+ />
40
+ ),
41
+ },
42
+ authAdapters: [
43
+ {
44
+ authType: "wecom",
45
+ placement: "button",
46
+ Component: WecomSignInButton,
47
+ },
48
+ ],
49
+ };
50
+
51
+ export default wecomAuthExtension;
@@ -0,0 +1 @@
1
+ export { isWecomBrowser, useWecomSignIn } from "./use-wecom-sign-in";
@@ -0,0 +1,64 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import { useSearchParams } from "react-router";
3
+
4
+ import type { Authenticator } from "@/components/auth/types";
5
+ import { nocobaseClient } from "@/lib/nocobase/client";
6
+ import { resolvePortalUrl } from "@/providers/runtime-config";
7
+
8
+ type WecomAuthUrlResponse = {
9
+ url?: string;
10
+ };
11
+
12
+ export function isWecomBrowser() {
13
+ return (
14
+ typeof navigator !== "undefined" &&
15
+ navigator.userAgent.toLowerCase().includes("wxwork")
16
+ );
17
+ }
18
+
19
+ export function useWecomSignIn(authenticator: Authenticator) {
20
+ const [searchParams] = useSearchParams();
21
+ const [isPending, setIsPending] = useState(false);
22
+ const [error, setError] = useState<Error>();
23
+ const callbackError = useMemo(() => {
24
+ if (searchParams.get("authenticator") !== authenticator.name) {
25
+ return undefined;
26
+ }
27
+ const message = searchParams.get("errorMsg");
28
+ return message ? new Error(message) : undefined;
29
+ }, [authenticator.name, searchParams]);
30
+
31
+ const signIn = useCallback(async () => {
32
+ setError(undefined);
33
+ setIsPending(true);
34
+ try {
35
+ const redirect = resolvePortalUrl(
36
+ searchParams.get("to") || searchParams.get("redirect") || "/"
37
+ );
38
+ const result = await nocobaseClient.action<WecomAuthUrlResponse>(
39
+ "wecom",
40
+ "getAuthUrl",
41
+ {
42
+ method: "POST",
43
+ authenticator: authenticator.name,
44
+ includeRole: false,
45
+ withAclMeta: false,
46
+ body: { redirect, isWeComBrowser: isWecomBrowser() },
47
+ }
48
+ );
49
+ if (!result?.url) {
50
+ throw new Error("NocoBase did not return a WeCom authorization URL.");
51
+ }
52
+ window.location.replace(nocobaseClient.resolveUrl(result.url));
53
+ } catch (cause) {
54
+ setError(
55
+ cause instanceof Error
56
+ ? cause
57
+ : new Error("Unable to start WeCom sign-in.")
58
+ );
59
+ setIsPending(false);
60
+ }
61
+ }, [authenticator.name, searchParams]);
62
+
63
+ return { signIn, isPending, error: error ?? callbackError };
64
+ }
@@ -0,0 +1,70 @@
1
+ import { Building2 } from "lucide-react";
2
+
3
+ import type { AuthenticatorComponentProps } from "@/components/auth/types";
4
+ import {
5
+ Alert,
6
+ AlertDescription,
7
+ AlertTitle,
8
+ } from "@/components/ui/alert";
9
+ import { Button } from "@/components/ui/button";
10
+ import {
11
+ Tooltip,
12
+ TooltipContent,
13
+ TooltipTrigger,
14
+ } from "@/components/ui/tooltip";
15
+ import { resolveTranslatableText } from "@/lib/i18n";
16
+
17
+ import { useWecomSignIn } from "./use-wecom-sign-in";
18
+
19
+ function plainText(value: unknown) {
20
+ return typeof value === "string"
21
+ ? value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
22
+ : "";
23
+ }
24
+
25
+ export default function WecomSignInButton({
26
+ authenticator,
27
+ onSignIn,
28
+ }: AuthenticatorComponentProps & { onSignIn?: () => void }) {
29
+ const { signIn, isPending, error } = useWecomSignIn(authenticator);
30
+ const tooltip = plainText(authenticator.options?.btnTooltip);
31
+ const button = (
32
+ <Button
33
+ type="button"
34
+ variant="outline"
35
+ className="w-full"
36
+ disabled={!onSignIn && isPending}
37
+ onClick={onSignIn ?? signIn}
38
+ >
39
+ <Building2 />
40
+ {!onSignIn && isPending
41
+ ? "Redirecting…"
42
+ : resolveTranslatableText(
43
+ authenticator.title ||
44
+ authenticator.authTypeTitle ||
45
+ "Sign in via WeCom"
46
+ )}
47
+ </Button>
48
+ );
49
+
50
+ return (
51
+ <div className="space-y-3">
52
+ {error && (
53
+ <Alert variant="destructive">
54
+ <AlertTitle>WeCom sign-in failed</AlertTitle>
55
+ <AlertDescription>{error.message}</AlertDescription>
56
+ </Alert>
57
+ )}
58
+ {tooltip ? (
59
+ <Tooltip>
60
+ <TooltipTrigger render={button} />
61
+ <TooltipContent side="right" className="max-w-xs">
62
+ {tooltip}
63
+ </TooltipContent>
64
+ </Tooltip>
65
+ ) : (
66
+ button
67
+ )}
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,44 @@
1
+ # NocoBase i18n
2
+
3
+ Optional NocoBase server-language integration and language controls for the
4
+ NocoBase Admin Starter.
5
+
6
+ The Starter owns the Refine i18n provider, local i18next runtime, application
7
+ resources, locale selection, and the shared `systemSettings:get` bootstrap.
8
+ This Registry reuses those foundations and adds only the NocoBase-specific
9
+ remote capabilities:
10
+
11
+ - load registered dynamic namespaces from `app:getLang`;
12
+ - persist a signed-in user's selected language through `users:updateLang`;
13
+ - expose reusable page and user-menu language switchers;
14
+ - provide an integrated Demo and Prompt generator.
15
+
16
+ When this Registry is not installed, the Starter still uses the system default
17
+ language and all local translations normally. It does not request
18
+ `app:getLang`. When installed, the Registry reads the already-cached system
19
+ settings and requests only registered server namespaces. `lm-collections` is
20
+ registered by default for collection and field metadata.
21
+
22
+ Application-owned React translations belong in `src/locales`, outside the
23
+ installed Registry directory:
24
+
25
+ ```ts
26
+ import { registerLocaleResources } from "@/providers/i18n";
27
+
28
+ registerLocaleResources("my-feature", {
29
+ "en-US": { title: "Orders" },
30
+ "zh-CN": { title: "订单" },
31
+ });
32
+ ```
33
+
34
+ Other installed components can opt into another server-generated namespace:
35
+
36
+ ```ts
37
+ import { registerServerResourceNamespace } from "@/extensions/nocobase-i18n";
38
+
39
+ registerServerResourceNamespace("my-dynamic-namespace");
40
+ ```
41
+
42
+ Namespaces registered after startup are loaded incrementally. Existing exact
43
+ NocoBase expressions such as `{{t("Orders")}}` remain supported by the
44
+ Starter's translation compatibility helper.
@@ -0,0 +1 @@
1
+ export * from "./language-switcher";
@@ -0,0 +1,138 @@
1
+ import { useGetLocale, useSetLocale, useTranslate } from "@refinedev/core";
2
+ import { Languages, Loader2 } from "lucide-react";
3
+ import { useState, type ReactNode } from "react";
4
+
5
+ import {
6
+ DropdownMenuRadioGroup,
7
+ DropdownMenuRadioItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuSub,
10
+ DropdownMenuSubContent,
11
+ DropdownMenuSubTrigger,
12
+ } from "@/components/ui/dropdown-menu";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "@/components/ui/select";
20
+ import { cn } from "@/lib/utils";
21
+ import { useEnabledLocales } from "@/providers/i18n";
22
+
23
+ export type LanguageSwitcherProps = {
24
+ className?: string;
25
+ label?: ReactNode | false;
26
+ triggerClassName?: string;
27
+ };
28
+
29
+ export function LanguageSwitcher({
30
+ className,
31
+ label,
32
+ triggerClassName,
33
+ }: LanguageSwitcherProps) {
34
+ const translate = useTranslate();
35
+ const getLocale = useGetLocale();
36
+ const setLocale = useSetLocale();
37
+ const locales = useEnabledLocales();
38
+ const currentLocale = getLocale();
39
+ const [switching, setSwitching] = useState(false);
40
+ const [error, setError] = useState<string>();
41
+ const resolvedLabel =
42
+ typeof label === "undefined"
43
+ ? translate("language.label", { ns: "nocobase-i18n" }, "Language")
44
+ : label;
45
+
46
+ if (locales.length < 2) return null;
47
+
48
+ const currentDefinition =
49
+ locales.find(({ locale }) => locale === currentLocale) ?? locales[0];
50
+
51
+ return (
52
+ <div className={cn("space-y-2", className)}>
53
+ {resolvedLabel === false ? null : (
54
+ <p className="text-xs font-medium text-muted-foreground">
55
+ {resolvedLabel}
56
+ </p>
57
+ )}
58
+ <Select
59
+ value={currentLocale}
60
+ disabled={switching}
61
+ onValueChange={(value) => {
62
+ if (!value || value === currentLocale) return;
63
+ setSwitching(true);
64
+ setError(undefined);
65
+ Promise.resolve(setLocale(value)).catch((reason) => {
66
+ setError(
67
+ reason instanceof Error
68
+ ? reason.message
69
+ : translate(
70
+ "language.switchError",
71
+ { ns: "nocobase-i18n" },
72
+ "Unable to switch language."
73
+ )
74
+ );
75
+ setSwitching(false);
76
+ });
77
+ }}
78
+ >
79
+ <SelectTrigger
80
+ className={cn("w-full min-w-52", triggerClassName)}
81
+ aria-label={String(resolvedLabel || "Language")}
82
+ >
83
+ {switching ? <Loader2 className="animate-spin" /> : <Languages />}
84
+ <SelectValue>{currentDefinition?.label ?? currentLocale}</SelectValue>
85
+ </SelectTrigger>
86
+ <SelectContent>
87
+ {locales.map((definition) => (
88
+ <SelectItem key={definition.locale} value={definition.locale}>
89
+ {definition.label}
90
+ </SelectItem>
91
+ ))}
92
+ </SelectContent>
93
+ </Select>
94
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function LanguageUserMenuItems() {
100
+ const translate = useTranslate();
101
+ const getLocale = useGetLocale();
102
+ const setLocale = useSetLocale();
103
+ const locales = useEnabledLocales();
104
+ const currentLocale = getLocale();
105
+
106
+ if (locales.length < 2) return null;
107
+
108
+ return (
109
+ <>
110
+ <DropdownMenuSeparator />
111
+ <DropdownMenuSub>
112
+ <DropdownMenuSubTrigger className="min-h-9 gap-2 px-2 text-muted-foreground focus:text-foreground">
113
+ <Languages />
114
+ <span>
115
+ {translate("language.label", { ns: "nocobase-i18n" }, "Language")}
116
+ </span>
117
+ </DropdownMenuSubTrigger>
118
+ <DropdownMenuSubContent className="min-w-44">
119
+ <DropdownMenuRadioGroup
120
+ value={currentLocale}
121
+ onValueChange={(value) => {
122
+ if (value && value !== currentLocale) void setLocale(value);
123
+ }}
124
+ >
125
+ {locales.map((definition) => (
126
+ <DropdownMenuRadioItem
127
+ key={definition.locale}
128
+ value={definition.locale}
129
+ >
130
+ {definition.label}
131
+ </DropdownMenuRadioItem>
132
+ ))}
133
+ </DropdownMenuRadioGroup>
134
+ </DropdownMenuSubContent>
135
+ </DropdownMenuSub>
136
+ </>
137
+ );
138
+ }