diff --git a/apps/remix/app/components/forms/password.tsx b/apps/remix/app/components/forms/password.tsx index 7f4bb0d5c..23ae901ab 100644 --- a/apps/remix/app/components/forms/password.tsx +++ b/apps/remix/app/components/forms/password.tsx @@ -58,7 +58,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { try { - await authClient.updatePassword({ + await authClient.emailPassword.updatePassword({ currentPassword, password, }); diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 5ea9a46f3..143455bd1 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; +import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; @@ -40,12 +41,20 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const CommonErrorMessages = { - // [AuthenticationErrorCode.USER_MISSING_PASSWORD]: - // 'This account appears to be using a social login method, please sign in using that method', +const CommonErrorMessages: Record = { [AuthenticationErrorCode.AccountDisabled]: msg`This account has been disabled. Please contact support.`, }; +const handleFallbackErrorMessages = (code: string) => { + const message = CommonErrorMessages[code]; + + if (!message) { + return msg`An unknown error occurred`; + } + + return message; +}; + const LOGIN_REDIRECT_PATH = '/documents'; export const ZSignInFormSchema = z.object({ @@ -163,12 +172,7 @@ export const SignInForm = ({ credential: JSON.stringify(credential), csrfToken: sessionId, redirectUrl, - // callbackUrl, - // redirect: false, }); - - // Todo: Can't use navigate because of embed? - // window.location.href = callbackUrl; } catch (err) { setIsPasskeyLoading(false); @@ -186,10 +190,10 @@ export const SignInForm = ({ msg`This passkey is not configured for this application. Please login and add one in the user settings.`, ) .with( - AuthenticationErrorCode.SessionExpired, // Todo + AuthenticationErrorCode.SessionExpired, () => msg`This session has expired. Please try again.`, ) - .otherwise(() => msg`Please try again later or login using your normal details`); + .otherwise(() => handleFallbackErrorMessages(error.code)); toast({ title: 'Something went wrong', @@ -208,11 +212,7 @@ export const SignInForm = ({ totpCode, backupCode, redirectUrl, - // callbackUrl, - // redirect: false, }); - - // window.location.href = callbackUrl; // Todo: Handle redirect. } catch (err) { console.log(err); @@ -245,7 +245,7 @@ export const SignInForm = ({ AuthenticationErrorCode.InvalidTwoFactorCode, () => msg`The two-factor authentication code provided is incorrect`, ) - .otherwise(() => msg`An unknown error occurred`); + .otherwise(() => handleFallbackErrorMessages(error.code)); toast({ title: _(msg`Unable to sign in`), @@ -257,7 +257,7 @@ export const SignInForm = ({ const onSignInWithGoogleClick = async () => { try { - await authClient.google.signIn(); // Todo: Handle redirect. + await authClient.google.signIn(); } catch (err) { toast({ title: _(msg`An unknown error occurred`), diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 7981a9243..3dc86cf6e 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -124,7 +124,13 @@ export const SignUpForm = ({ const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormSchema) => { try { - await authClient.emailPassword.signUp({ name, email, password, signature, url }); + await authClient.emailPassword.signUp({ + name, + email, + password, + signature, + url, + }); await navigate(`/unverified-account`); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx index 0c8d0097b..9f93144d1 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import { RecipientRole } from '@prisma/client'; -import { useNavigate } from 'react-router'; import { authClient } from '@documenso/auth/client'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; @@ -24,7 +24,9 @@ export const DocumentSigningAuthAccount = ({ }: DocumentSigningAuthAccountProps) => { const { recipient } = useRequiredDocumentSigningAuthContext(); - const navigate = useNavigate(); + const { t } = useLingui(); + + const { toast } = useToast(); const [isSigningOut, setIsSigningOut] = useState(false); @@ -32,18 +34,18 @@ export const DocumentSigningAuthAccount = ({ try { setIsSigningOut(true); - // Todo - await authClient.signOut(); - // { - // // redirect: false, - // // Todo: Redirect to signin like below - // } - - await navigate(`/signin#email=${email}`); + await authClient.signOut({ + redirectUrl: `/signin#email=${email}`, + }); } catch { setIsSigningOut(false); - // Todo: Alert. + toast({ + title: t`Something went wrong`, + description: t`We were unable to log you out at this time.`, + duration: 10000, + variant: 'destructive', + }); } }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx index 791c4120f..98fcb8b9a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { useNavigate } from 'react-router'; import { authClient } from '@documenso/auth/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -21,18 +20,15 @@ export const DocumentSigningAuthPageView = ({ const { _ } = useLingui(); const { toast } = useToast(); - const navigate = useNavigate(); - const [isSigningOut, setIsSigningOut] = useState(false); const handleChangeAccount = async (email: string) => { try { setIsSigningOut(true); - // Todo: Redirect false - await authClient.signOut(); - - await navigate(emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`); + await authClient.signOut({ + redirectUrl: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`, + }); } catch { toast({ title: _(msg`Something went wrong`), diff --git a/apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx b/apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx index 71cc03feb..d1dcafb51 100644 --- a/apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx @@ -27,8 +27,6 @@ export const loader = ({ params }: Route.LoaderArgs) => { }; export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) { - console.log('hello world'); - const { token } = loaderData; const { _ } = useLingui(); @@ -42,7 +40,6 @@ export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) { setIsLoading(true); try { - // Todo: Types and check. const response = await authClient.emailPassword.verifyEmail({ token, }); diff --git a/apps/remix/vite.config.ts b/apps/remix/vite.config.ts index b2892acfd..c75da55b1 100644 --- a/apps/remix/vite.config.ts +++ b/apps/remix/vite.config.ts @@ -40,8 +40,9 @@ export default defineConfig({ external: ['@node-rs/bcrypt', '@prisma/client'], }, optimizeDeps: { - // include: ['react-icons'], - exclude: ['@node-rs/bcrypt'], + entries: ['./app/**/*', '../../packages/ui/**/*', '../../packages/lib/**/*'], + include: ['prop-types', 'file-selector', 'attr-accept'], + exclude: ['node_modules', '@node-rs/bcrypt', '@documenso/pdf-sign', 'sharp'], }, resolve: { alias: { diff --git a/packages/auth/client/index.ts b/packages/auth/client/index.ts index 9a57d152b..fb2ed00f7 100644 --- a/packages/auth/client/index.ts +++ b/packages/auth/client/index.ts @@ -1,109 +1,112 @@ -import type { ClientResponse } from 'hono/client'; +import type { ClientResponse, InferRequestType } from 'hono/client'; import { hc } from 'hono/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError } from '@documenso/lib/errors/app-error'; import type { AuthAppType } from '../server'; +import { handleSignInRedirect } from '../server/lib/utils/redirect'; import type { TForgotPasswordSchema, TResetPasswordSchema, - TSignInSchema, TSignUpSchema, + TUpdatePasswordSchema, TVerifyEmailSchema, } from '../server/types/email-password'; -import type { TPasskeyAuthorizeSchema } from '../server/types/passkey'; + +type AuthClientType = ReturnType>; + +type TEmailPasswordSignin = InferRequestType< + AuthClientType['email-password']['authorize']['$post'] +>['json'] & { redirectPath?: string }; + +type TPasskeySignin = InferRequestType['json'] & { + redirectPath?: string; +}; export class AuthClient { - public client: ReturnType>; + public client: AuthClientType; - private signOutRedirectUrl: string = '/signin'; + private signOutredirectPath: string = '/signin'; constructor(options: { baseUrl: string }) { this.client = hc(options.baseUrl); } - public async signOut() { + public async signOut({ redirectPath }: { redirectPath?: string } = {}) { await this.client.signout.$post(); - window.location.href = this.signOutRedirectUrl; + window.location.href = redirectPath ?? this.signOutredirectPath; } public async session() { return this.client.session.$get(); } - private async handleResponse(response: ClientResponse) { + private async handleError(response: ClientResponse): Promise { if (!response.ok) { const error = await response.json(); throw AppError.parseError(error); } - - if (response.headers.get('content-type')?.includes('application/json')) { - return response.json(); - } - - return response.text(); } public emailPassword = { - signIn: async (data: TSignInSchema & { redirectUrl?: string }) => { - const response = await this.client['email-password'].authorize - .$post({ json: data }) - .then(this.handleResponse); + signIn: async (data: TEmailPasswordSignin) => { + const response = await this.client['email-password'].authorize.$post({ json: data }); + await this.handleError(response); - if (data.redirectUrl) { - window.location.href = data.redirectUrl; - } + handleSignInRedirect(data.redirectPath); + }, - return response; + updatePassword: async (data: TUpdatePasswordSchema) => { + const response = await this.client['email-password']['update-password'].$post({ json: data }); + await this.handleError(response); }, forgotPassword: async (data: TForgotPasswordSchema) => { const response = await this.client['email-password']['forgot-password'].$post({ json: data }); - return this.handleResponse(response); + await this.handleError(response); }, resetPassword: async (data: TResetPasswordSchema) => { const response = await this.client['email-password']['reset-password'].$post({ json: data }); - return this.handleResponse(response); + await this.handleError(response); }, signUp: async (data: TSignUpSchema) => { const response = await this.client['email-password']['signup'].$post({ json: data }); - return this.handleResponse(response); + await this.handleError(response); }, verifyEmail: async (data: TVerifyEmailSchema) => { const response = await this.client['email-password']['verify-email'].$post({ json: data }); - return this.handleResponse(response); + await this.handleError(response); + + return response.json(); }, }; public passkey = { - signIn: async (data: TPasskeyAuthorizeSchema & { redirectUrl?: string }) => { - const response = await this.client['passkey'].authorize - .$post({ json: data }) - .then(this.handleResponse); + signIn: async (data: TPasskeySignin) => { + const response = await this.client['passkey'].authorize.$post({ json: data }); + await this.handleError(response); - if (data.redirectUrl) { - window.location.href = data.redirectUrl; - } - - return response; + handleSignInRedirect(data.redirectPath); }, }; public google = { - signIn: async () => { - const response = await this.client['google'].authorize.$post().then(this.handleResponse); + signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => { + const response = await this.client['google'].authorize.$post({ json: { redirectPath } }); + await this.handleError(response); - if (response.redirectUrl) { - window.location.href = response.redirectUrl; + const data = await response.json(); + + // Redirect to external Google auth URL. + if (data.redirectUrl) { + window.location.href = data.redirectUrl; } - - return response; }, }; } diff --git a/packages/auth/server/lib/session/session-cookies.ts b/packages/auth/server/lib/session/session-cookies.ts index 1073c77bc..4fe042ef0 100644 --- a/packages/auth/server/lib/session/session-cookies.ts +++ b/packages/auth/server/lib/session/session-cookies.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono'; -import { getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { appLog } from '@documenso/lib/utils/debugger'; import { env } from '@documenso/lib/utils/env'; @@ -16,6 +17,12 @@ const getAuthSecret = () => { return authSecret; }; +const getAuthDomain = () => { + const url = new URL(NEXT_PUBLIC_WEBAPP_URL()); + + return url.hostname; +}; + export const extractSessionCookieFromHeaders = (headers: Headers): string | null => { const cookieHeader = headers.get('cookie') || ''; const cookiePairs = cookieHeader.split(';'); @@ -51,10 +58,24 @@ export const setSessionCookie = async (c: Context, sessionToken: string) => { path: '/', // sameSite: '', // whats the default? we need to change this for embed right? // secure: true, - domain: 'localhost', // todo + domain: getAuthDomain(), }).catch((err) => { appLog('SetSessionCookie', `Error setting signed cookie: ${err}`); throw err; }); }; + +/** + * Set the session cookie into the Hono context. + * + * @param c - The Hono context. + * @param sessionToken - The session token to set. + */ +export const deleteSessionCookie = (c: Context) => { + deleteCookie(c, sessionCookieName, { + path: '/', + secure: true, + domain: getAuthDomain(), + }); +}; diff --git a/packages/auth/server/lib/utils/redirect.ts b/packages/auth/server/lib/utils/redirect.ts new file mode 100644 index 000000000..c1a27779d --- /dev/null +++ b/packages/auth/server/lib/utils/redirect.ts @@ -0,0 +1,28 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; + +/** + * Handle an optional redirect path. + */ +export const handleRequestRedirect = (redirectUrl?: string) => { + if (!redirectUrl) { + return; + } + + const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL()); + + if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) { + window.location.href = '/documents'; + } else { + window.location.href = redirectUrl; + } +}; + +export const handleSignInRedirect = (redirectUrl: string = '/documents') => { + const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL()); + + if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) { + window.location.href = '/documents'; + } else { + window.location.href = redirectUrl; + } +}; diff --git a/packages/auth/server/routes/google.ts b/packages/auth/server/routes/google.ts index e1863dcf6..2a8f9c1de 100644 --- a/packages/auth/server/routes/google.ts +++ b/packages/auth/server/routes/google.ts @@ -1,21 +1,22 @@ +import { zValidator } from '@hono/zod-validator'; import { Google, decodeIdToken, generateCodeVerifier, generateState } from 'arctic'; import { Hono } from 'hono'; -import { getCookie, setCookie } from 'hono/cookie'; +import { deleteCookie, setCookie } from 'hono/cookie'; +import { z } from 'zod'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; import { env } from '@documenso/lib/utils/env'; import { prisma } from '@documenso/prisma'; import { AuthenticationErrorCode } from '../lib/errors/error-codes'; import { onAuthorize } from '../lib/utils/authorizer'; -import { getRequiredSession } from '../lib/utils/get-session'; import type { HonoAuthContext } from '../types/context'; const options = { - clientId: env('NEXT_PRIVATE_GOOGLE_CLIENT_ID'), - clientSecret: env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'), - redirectUri: 'http://localhost:3000/api/auth/google/callback', + clientId: env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') ?? '', + clientSecret: env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET') ?? '', + redirectUri: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/google/callback`, scope: ['openid', 'email', 'profile'], id: 'google', }; @@ -24,55 +25,62 @@ const google = new Google(options.clientId, options.clientSecret, options.redire // todo: NEXT_PRIVATE_OIDC_WELL_KNOWN??? +const ZGoogleAuthorizeSchema = z.object({ + redirectPath: z.string().optional(), +}); + export const googleRoute = new Hono() /** * Authorize endpoint. */ - .post('/authorize', (c) => { + .post('/authorize', zValidator('json', ZGoogleAuthorizeSchema), (c) => { const scopes = options.scope; const state = generateState(); + const codeVerifier = generateCodeVerifier(); const url = google.createAuthorizationURL(state, codeVerifier, scopes); + const { redirectPath } = c.req.valid('json'); + setCookie(c, 'google_oauth_state', state, { path: '/', httpOnly: true, - secure: env('NODE_ENV') === 'production', + secure: env('NODE_ENV') === 'production', // Todo: Check. maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', + sameSite: 'lax', // Todo?? }); setCookie(c, 'google_code_verifier', codeVerifier, { path: '/', httpOnly: true, - // Todo: Might not be node_env but something vite specific? - secure: env('NODE_ENV') === 'production', + secure: env('NODE_ENV') === 'production', // Todo: Check. maxAge: 60 * 10, // 10 minutes - sameSite: 'lax', + sameSite: 'lax', // Todo?? }); - // return new Response(null, { - // status: 302, - // headers: { - // Location: url.toString() - // } - // }); + if (redirectPath) { + setCookie(c, 'google_redirect_path', `${state}:${redirectPath}`, { + path: '/', + httpOnly: true, + secure: env('NODE_ENV') === 'production', // Todo: Check. + maxAge: 60 * 10, // 10 minutes + sameSite: 'lax', // Todo?? + }); + } return c.json({ - redirectUrl: url, + redirectUrl: url.toString(), }); }) /** * Google callback verification. */ .get('/callback', async (c) => { - // Todo: Use ZValidator to validate query params. - const code = c.req.query('code'); const state = c.req.query('state'); - const storedState = getCookie(c, 'google_oauth_state'); - const storedCodeVerifier = getCookie(c, 'google_code_verifier'); + const storedState = deleteCookie(c, 'google_oauth_state'); + const storedCodeVerifier = deleteCookie(c, 'google_code_verifier'); if (!code || !storedState || state !== storedState || !storedCodeVerifier) { throw new AppError(AppErrorCode.INVALID_REQUEST, { @@ -80,18 +88,23 @@ export const googleRoute = new Hono() }); } + const storedredirectPath = deleteCookie(c, 'google_redirect_path') ?? ''; + + // eslint-disable-next-line prefer-const + let [redirectState, redirectPath] = storedredirectPath.split(':'); + + if (redirectState !== storedState || !redirectPath) { + redirectPath = '/documents'; + } + const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier); const accessToken = tokens.accessToken(); const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); const idToken = tokens.idToken(); - console.log(tokens); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const claims = decodeIdToken(tokens.idToken()) as Record; - console.log(claims); - const googleEmail = claims.email; const googleName = claims.name; const googleSub = claims.sub; @@ -127,7 +140,7 @@ export const googleRoute = new Hono() if (existingAccount) { await onAuthorize({ userId: existingAccount.user.id }, c); - return c.redirect('/documents', 302); // Todo: Redirect + return c.redirect(redirectPath, 302); } const userWithSameEmail = await prisma.user.findFirst({ @@ -154,7 +167,7 @@ export const googleRoute = new Hono() // Todo: Link account await onAuthorize({ userId: userWithSameEmail.id }, c); - return c.redirect('/documents', 302); // Todo: Redirect + return c.redirect(redirectPath, 302); } // Handle new user. @@ -184,21 +197,5 @@ export const googleRoute = new Hono() await onAuthorize({ userId: createdUser.id }, c); - return c.redirect('/documents', 302); // Todo: Redirect - }) - /** - * Setup passkey authentication. - */ - .post('/setup', async (c) => { - const { user } = await getRequiredSession(c); - - const result = await setupTwoFactorAuthentication({ - user, - }); - - return c.json({ - success: true, - secret: result.secret, - uri: result.uri, - }); + return c.redirect(redirectPath, 302); }); diff --git a/packages/auth/server/routes/sign-out.ts b/packages/auth/server/routes/sign-out.ts index 6079e6ba0..54975ca12 100644 --- a/packages/auth/server/routes/sign-out.ts +++ b/packages/auth/server/routes/sign-out.ts @@ -1,11 +1,10 @@ import { Hono } from 'hono'; -import { deleteCookie, getSignedCookie } from 'hono/cookie'; import { invalidateSession, validateSessionToken } from '../lib/session/session'; +import { deleteSessionCookie, getSessionCookie } from '../lib/session/session-cookies'; export const signOutRoute = new Hono().post('/signout', async (c) => { - // todo: secret - const sessionId = await getSignedCookie(c, 'secret', 'sessionId'); + const sessionId = await getSessionCookie(c); if (!sessionId) { return new Response('No session found', { status: 401 }); @@ -19,11 +18,7 @@ export const signOutRoute = new Hono().post('/signout', async (c) => { await invalidateSession(session.id); - deleteCookie(c, 'sessionId', { - path: '/', - secure: true, - domain: 'example.com', - }); + deleteSessionCookie(c); return c.status(200); }); diff --git a/packages/lib/utils/i18n.ts b/packages/lib/utils/i18n.ts index 8dba1a3aa..72f7838ef 100644 --- a/packages/lib/utils/i18n.ts +++ b/packages/lib/utils/i18n.ts @@ -3,13 +3,13 @@ import { i18n } from '@lingui/core'; import type { I18nLocaleData, SupportedLanguageCodes } from '../constants/i18n'; import { APP_I18N_OPTIONS } from '../constants/i18n'; -import { env } from './env'; export async function dynamicActivate(locale: string) { - const extension = env('NODE_ENV') === 'development' ? 'po' : 'js'; + // const extension = import.meta.env.PROD env('NODE_ENV') === 'development' ? 'po' : 'js'; + // eslint-disable-next-line turbo/no-undeclared-env-vars + const extension = import.meta.env.PROD ? 'js' : 'po'; // Todo: Use extension (currently breaks). - const { messages } = await import(`../translations/${locale}/web.${extension}`); i18n.loadAndActivate({ locale, messages });