diff --git a/apps/web/content/docs/dev/advanced/auth.mdx b/apps/web/content/docs/dev/advanced/auth.mdx index cbd98334b3..1029ceddc4 100644 --- a/apps/web/content/docs/dev/advanced/auth.mdx +++ b/apps/web/content/docs/dev/advanced/auth.mdx @@ -162,7 +162,7 @@ With passwords off: - `/login` hides the email + password form and **Forgot password?**. It keeps [SSO](/docs/dev/sso) buttons and **Sign in with a passkey** - if neither is configured, members see a "Signing in is unavailable" notice. - `/register` hides the sign-up form. New accounts can still come in through SSO; with no SSO provider, the page says registration is unavailable and the "Sign up" link on `/login` goes away. -- `/login/reset-password` answers "not found". +- `/login/reset-password` (the page a reset email links to) answers "not found". - The API refuses `POST /users/sign_in` (for members), `/users/sign_up`, `/users/reset-password`, `/users/change-password` and password-based SSO linking with `403`. - A stored password no longer counts as a way into the account, so members can't remove their last [passkey](/docs/dev/passkeys) unless they have another passkey or a linked SSO provider. diff --git a/apps/web/content/docs/dev/captcha/cloudflare.mdx b/apps/web/content/docs/dev/captcha/cloudflare.mdx index 346ea287d5..d6e30d2bfe 100644 --- a/apps/web/content/docs/dev/captcha/cloudflare.mdx +++ b/apps/web/content/docs/dev/captcha/cloudflare.mdx @@ -38,10 +38,12 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -Restart the API and the widget appears on `/register`, and on -`/login/reset-password` on a deployment that has an -[email adapter](/docs/dev/email) to send the link with. The rest of this page is -how to get those two values. +Restart the API and the widget appears on `/register`, and - on a deployment +that has an [email adapter](/docs/dev/email) to send the link with - in the +**Forgot password?** step on `/login`. With a captcha configured, that step asks +for the email again next to the widget; without one, **Forgot password?** sends +the link to the address already typed in the login form. The rest of this page +is how to get those two values. diff --git a/apps/web/content/docs/dev/sso/discord.mdx b/apps/web/content/docs/dev/sso/discord.mdx index b54a4c46cd..54e8816859 100644 --- a/apps/web/content/docs/dev/sso/discord.mdx +++ b/apps/web/content/docs/dev/sso/discord.mdx @@ -177,7 +177,7 @@ Then open `/login` and press **Discord**. You should see Discord's "connect to" authorization prompt listing your username and email, get bounced back to `/login/sso/discord?code=...&state=...`, and land on the front page signed in. -{/* Image prompt: The VitNode /login page with the email and password fields below a blurple button labelled "Continue with Discord" and a divider reading "Or continue with email", dark theme, 900x850. */} +{/* Image prompt: The VitNode /login page split in two columns - left a "Continue with" heading over a blurple "Discord" button with a "Last used" badge, right the email and password form, a vertical "or" divider between them, dark theme, 900x600. */} diff --git a/apps/web/content/docs/dev/sso/facebook.mdx b/apps/web/content/docs/dev/sso/facebook.mdx index fb4d39874b..de4baa9269 100644 --- a/apps/web/content/docs/dev/sso/facebook.mdx +++ b/apps/web/content/docs/dev/sso/facebook.mdx @@ -212,7 +212,7 @@ Then open `/login` and press **Facebook**. You should get Meta's "Continue as land on the front page signed in. Your account now has a row in `core_users_sso` with `providerId` `facebook`. -{/* Image prompt: The VitNode /login page with the password form above a divider reading "Or continue With" and two outline buttons labelled Google and Facebook side by side, dark theme, 900x800. */} +{/* Image prompt: The VitNode /login page split in two columns - left a "Continue with" heading over a neutral "Google" button and a blue "Facebook" button side by side, right the email and password form, a vertical "or" divider between them, dark theme, 900x600. */} diff --git a/apps/web/content/docs/dev/sso/icons.mdx b/apps/web/content/docs/dev/sso/icons.mdx index d477a88235..454ec62ce9 100644 --- a/apps/web/content/docs/dev/sso/icons.mdx +++ b/apps/web/content/docs/dev/sso/icons.mdx @@ -10,15 +10,15 @@ import { ImgDocs } from '@/components/fumadocs/img' import loginSsoButtons from './icons/login-sso-buttons.png' -Every SSO provider gets a full-width **Continue with _Provider_** button on -`/login` and `/register`. The built-in Google, Discord and Facebook adapters +Every SSO provider gets a button labelled with its name on `/login` and +`/register` ([where they go](/docs/dev/sso#on-the-login-page)). The built-in Google, Discord and Facebook adapters already carry their brand marks and colors, so those buttons look right without any configuration. A custom adapter starts out as a neutral button with text only - a **GitHub** button with no octocat - and this page shows how to give it a mark and a color. diff --git a/apps/web/content/docs/dev/sso/icons/login-sso-buttons.png b/apps/web/content/docs/dev/sso/icons/login-sso-buttons.png index 21b13f3b77..d6c69224ea 100644 Binary files a/apps/web/content/docs/dev/sso/icons/login-sso-buttons.png and b/apps/web/content/docs/dev/sso/icons/login-sso-buttons.png differ diff --git a/apps/web/content/docs/dev/sso/index.mdx b/apps/web/content/docs/dev/sso/index.mdx index c95b087121..a6af4571ef 100644 --- a/apps/web/content/docs/dev/sso/index.mdx +++ b/apps/web/content/docs/dev/sso/index.mdx @@ -27,10 +27,27 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -A **Continue with Google** button automatically renders on your login and -registration forms, four-color G included. The built-in adapters ship their own -marks and colors - Discord and Facebook buttons are filled with their brand -color - and a custom adapter can [add its own](/docs/dev/sso/icons). +A **Google** button automatically renders on your login and registration forms, +four-color G included. The built-in adapters ship their own marks and colors - +Discord and Facebook buttons are filled with their brand color - and a custom +adapter can [add its own](/docs/dev/sso/icons). + +## On the login page + +The provider buttons sit in their own column next to the email form, under one +**Continue with** heading, so each button only needs the provider's name (screen +readers still hear "Continue with Google"). On small screens the columns stack: +providers first, then the form. + +- `/login` packs the providers into a two-column grid, so six of them take three + rows instead of six. The passkey button goes right below. +- `/register` stacks them full width, because the sign-up form next to them is + taller anyway. +- The method someone signed in with last - a provider, a passkey or a password - + gets a **Last used** badge, and a last-used provider moves to the top of the + grid. It's remembered in that browser only after a successful sign-in, so a + visitor who gives up halfway through Discord's consent screen won't see it. +- Only one kind of method enabled? The card shrinks back to a single column. --- diff --git a/apps/web/content/docs/ui/view-transitions.mdx b/apps/web/content/docs/ui/view-transitions.mdx index 9a99757f83..ee36d8235f 100644 --- a/apps/web/content/docs/ui/view-transitions.mdx +++ b/apps/web/content/docs/ui/view-transitions.mdx @@ -64,6 +64,15 @@ logo in your own layout to join in. too. +## Login and register + +Click **Sign up** under the login form. The card doesn't blink out and back: +it glides to its new size while the old fields fade out and the new ones fade +in. The card carries the `vitnode-auth-card` name, and +`@vitnode/core/styles/view-transitions.css` styles it with no extra code on +your side. The "Last used" badge on a sign-in button fades in on its own once +the browser knows which method you picked last time. + ## Animate a component with ViewTransition diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index f90d66fde4..3b41f23ebe 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -1756,7 +1756,12 @@ "subject": "Zresetuj swoje hasło" }, "submit": "Wyślij link resetujący", - "title": "Resetowanie hasła" + "title": "Resetowanie hasła", + "address": { + "label": "E-mail", + "invalid": "Nieprawidłowy adres e-mail." + }, + "back": "Wróć do logowania" }, "settings": { "devices": { @@ -2257,7 +2262,8 @@ "unavailable": { "desc": "Na tej stronie nie jest teraz włączony żaden sposób logowania. Zajrzyj później.", "title": "Logowanie jest niedostępne" - } + }, + "last_used": "Ostatnio używane" }, "sign_up": { "already_have_account": "Masz już konto? Zaloguj się.", @@ -2307,6 +2313,7 @@ } }, "sso": { + "heading": "Kontynuuj przez", "access_denied": "Odmówiono dostępu do aplikacji lub żądanie wygasło. Spróbuj ponownie.", "email_exists": { "desc": "Konto z tym adresem e-mail już istnieje. Zaloguj się dotychczasową metodą, aby połączyć konto .", @@ -2340,7 +2347,6 @@ "submit": "Połącz i zaloguj się", "title": "Połącz ze swoim kontem" }, - "or": "Lub kontynuuj przez e-mail", "continue_with": "Kontynuuj przez {provider}", "connection": { "pending": "Kończymy z {provider}…" diff --git a/dump.rdb b/dump.rdb deleted file mode 100644 index 68e20e0a5c..0000000000 Binary files a/dump.rdb and /dev/null differ diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 7edd445d22..3f8aef77bf 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -910,7 +910,7 @@ }, "auth": { "sso": { - "or": "Or continue with email", + "heading": "Continue with", "continue_with": "Continue with {provider}", "access_denied": "You have denied access to the application or the request has expired. Please try again.", "email_exists": { @@ -1061,7 +1061,8 @@ "unavailable": { "title": "Signing in is unavailable", "desc": "This site has no sign-in method switched on right now. Please check back later." - } + }, + "last_used": "Last used" }, "reset_password": { "title": "Reset Password", @@ -1071,7 +1072,12 @@ "title": "Check your email", "desc": "We've sent a password reset link to your email address:", "check_spam": "If you don't see the email in your inbox, please check your spam folder." - } + }, + "address": { + "label": "Email", + "invalid": "Invalid email address." + }, + "back": "Back to login" }, "change_password": { "title": "Change Password", diff --git a/packages/vitnode/src/pages/login/reset-password.tsx b/packages/vitnode/src/pages/login/reset-password.tsx index 186abb5462..4d558fb5a3 100644 --- a/packages/vitnode/src/pages/login/reset-password.tsx +++ b/packages/vitnode/src/pages/login/reset-password.tsx @@ -1,4 +1,4 @@ -import { notFound } from "@tanstack/react-router"; +import { notFound, redirect } from "@tanstack/react-router"; import type { PluginRoutePageProps } from "@/routing"; import type { PasswordResetSearch } from "@/tanstack/auth/recovery"; @@ -15,6 +15,7 @@ import { PasswordRecoveryNotFound, PasswordResetRouteContent, } from "@/tanstack/auth/recovery-screen"; +import { LOGIN_PATH } from "@/tanstack/auth/redirects"; import { ErrorActions } from "@/tanstack/layout/error-actions"; import { defineRoute } from "@/tanstack/plugin-routes"; @@ -47,10 +48,12 @@ export const route = defineRoute({ // oxlint-disable-next-line typescript/only-throw-error if (availability === "disabled") throw notFound(); - return await loadPasswordResetRoute({ - ...context, - mode: passwordResetMode(search).mode, - }); + const { mode } = passwordResetMode(search); + + // oxlint-disable-next-line typescript/only-throw-error + if (mode === "request") throw redirect({ replace: true, to: LOGIN_PATH }); + + return await loadPasswordResetRoute({ ...context, mode }); }, head: ({ t }) => ({ title: t("core.auth.reset_password.title") }), diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 2cc38c93f2..6a74580d85 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -41,6 +41,10 @@ import { ADMIN_USER_NAMESPACES } from "./tanstack/admin/users/detail-route"; import { ADMIN_USERS_NAMESPACES } from "./tanstack/admin/users/route"; import { normalizeUsersRouteSearch } from "./tanstack/admin/users/route-search"; import { LOGIN_NAMESPACES } from "./tanstack/auth/login-route"; +import { + LoginPendingSkeleton, + RegisterPendingSkeleton, +} from "./tanstack/auth/pending"; import { normalizePasswordResetSearch, PASSWORD_RESET_BASE_NAMESPACES, @@ -121,7 +125,7 @@ export const routes = defineRoutes([ page("/login", { component: lazy(() => import("./pages/login/index")), messages: LOGIN_NAMESPACES, - pendingComponent: AuthPendingSkeleton, + pendingComponent: LoginPendingSkeleton, requires: "guest", search: normalizeLoginSearch, }), @@ -129,7 +133,7 @@ export const routes = defineRoutes([ page("/register", { component: lazy(() => import("./pages/register")), messages: REGISTER_NAMESPACES, - pendingComponent: AuthPendingSkeleton, + pendingComponent: RegisterPendingSkeleton, requires: "guest", }), diff --git a/packages/vitnode/src/tanstack/auth/actions.ts b/packages/vitnode/src/tanstack/auth/actions.ts index 8ec67e54eb..3d2b3016ae 100644 --- a/packages/vitnode/src/tanstack/auth/actions.ts +++ b/packages/vitnode/src/tanstack/auth/actions.ts @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; import type { ChangePasswordSubmit } from "@/views/auth/password-reset/change-password-form/change-password-form-content"; -import type { PasswordResetSubmit } from "@/views/auth/password-reset/form/password-reset-form-content"; +import type { PasswordResetSubmit } from "@/views/auth/password-reset/form/use-password-reset-form"; import type { SignInSubmit } from "@/views/auth/sign-in/form/sign-in-form-content"; import type { PasskeySignInSubmit } from "@/views/auth/sign-in/passkey/passkey-sign-in-button"; import type { SignUpSubmit } from "@/views/auth/sign-up/form/sign-up-form-content"; @@ -14,6 +14,10 @@ import type { ResendVerificationSubmit, } from "@/views/auth/verify-email/schema"; +import { + rememberSignInMethod, + ssoSignInMethod, +} from "@/views/auth/last-sign-in-method"; import { getPasskeyInBrowser } from "@/views/auth/passkeys/webauthn"; import type { SsoCallbackInput } from "./contract"; @@ -54,6 +58,7 @@ export const useSignInAction = ({ if (!result.ok) return signInFormResult(result); + rememberSignInMethod("password"); // Somebody has just identified themselves, and it may not be whoever this // browser held data for. Dropping it costs nothing when there is none - // which is the usual case on the public login page - and forces every @@ -97,6 +102,8 @@ export const usePasskeySignInAction = ({ return result.reason === "access_denied" ? "rejected" : result.reason; } + rememberSignInMethod("passkey"); + removeAdminIdentityQueries(queryClient); removeUserIdentityQueries(queryClient); @@ -126,6 +133,7 @@ export const useCompleteSsoAction = (params: null | SsoCallbackInput) => { const result = await authTransport().completeSso(params); if (result.ok) { + rememberSignInMethod(ssoSignInMethod(params.providerId)); // The same identity boundary the password sign-in above crosses, reached // by a different door: a provider has just told this application who the // visitor is. Whatever the tab was holding - the AdminCP's privileged @@ -158,6 +166,8 @@ export const useLinkSsoAction = ({ if (!result.ok) return ssoLinkFormResult(result); + rememberSignInMethod(ssoSignInMethod(providerId)); + removeAdminIdentityQueries(queryClient); removeUserIdentityQueries(queryClient); await invalidateSession(queryClient); diff --git a/packages/vitnode/src/tanstack/auth/login-route.tsx b/packages/vitnode/src/tanstack/auth/login-route.tsx index 64140fd58d..92d65f0a1e 100644 --- a/packages/vitnode/src/tanstack/auth/login-route.tsx +++ b/packages/vitnode/src/tanstack/auth/login-route.tsx @@ -6,6 +6,7 @@ export const LOGIN_NAMESPACES = [ "core.global", "core.auth.sign_in", "core.auth.sso", + "core.auth.reset_password", ] as const; /** The narrowest slice of a route's context these loaders read. */ diff --git a/packages/vitnode/src/tanstack/auth/login-screen.tsx b/packages/vitnode/src/tanstack/auth/login-screen.tsx index 175bb4605c..a2433d316c 100644 --- a/packages/vitnode/src/tanstack/auth/login-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/login-screen.tsx @@ -1,3 +1,7 @@ +import { + ssoProviderOfMethod, + useLastSignInMethod, +} from "@/views/auth/last-sign-in-method"; import { SignInFormContent } from "@/views/auth/sign-in/form/sign-in-form-content"; import { PasskeySignInButton } from "@/views/auth/sign-in/passkey/passkey-sign-in-button"; import { SignInContent } from "@/views/auth/sign-in/sign-in-content"; @@ -5,6 +9,7 @@ import { SSOButtonsContent } from "@/views/auth/sso/buttons/sso-buttons-content" import { RouteMessages } from "../i18n/route-messages"; import { + requestPasswordResetAction, startSsoAction, usePasskeySignInAction, useSignInAction, @@ -25,6 +30,7 @@ export interface LoginRouteProps { export const LoginRouteContent = ({ returnTo }: LoginRouteProps) => { const { data: config } = useMiddlewareConfigQuery(); const methods = authMethodsOf(config); + const lastMethod = useLastSignInMethod(); const signIn = useSignInAction({ destination: () => postAuthDestination(returnTo), }); @@ -38,6 +44,8 @@ export const LoginRouteContent = ({ returnTo }: LoginRouteProps) => { form={ methods.password ? ( @@ -47,18 +55,21 @@ export const LoginRouteContent = ({ returnTo }: LoginRouteProps) => { passkey={ methods.passkey ? ( 0} + showDivider={false} /> ) : undefined } showSignUp={methods.signUp} sso={ - + methods.sso.length > 0 ? ( + + ) : undefined } /> diff --git a/packages/vitnode/src/tanstack/auth/pending.tsx b/packages/vitnode/src/tanstack/auth/pending.tsx new file mode 100644 index 0000000000..f0c9ad22a9 --- /dev/null +++ b/packages/vitnode/src/tanstack/auth/pending.tsx @@ -0,0 +1,71 @@ +import { useQueryClient } from "@tanstack/react-query"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { AuthCardSkeleton } from "@/views/auth/auth-card"; +import { SignInFormSkeleton } from "@/views/auth/sign-in/form/sign-in-form-skeleton"; +import { SignUpFormSkeleton } from "@/views/auth/sign-up/form/sign-up-form-skeleton"; +import { SSOButtonsSkeleton } from "@/views/auth/sso/buttons/sso-buttons-skeleton"; + +import { + authMethodsOf, + middlewareConfigQueryOptions, +} from "./middleware-config"; + +const DEFAULT_SSO_COUNT = 2; + +const useCachedConfig = () => { + const data = useQueryClient().getQueryData( + middlewareConfigQueryOptions().queryKey, + ); + + return data?.isKnown ? data : undefined; +}; + +export const LoginPendingSkeleton = () => { + const config = useCachedConfig(); + const methods = config ? authMethodsOf(config) : undefined; + const ssoCount = methods ? methods.sso.length : DEFAULT_SSO_COUNT; + const hasPasskey = methods?.passkey ?? false; + const hasAlternatives = ssoCount > 0 || hasPasskey; + + return ( + + {ssoCount > 0 ? : null} + {hasPasskey ? : null} + + ) : undefined + } + form={ + (methods?.password ?? true) ? ( + + ) : undefined + } + /> + ); +}; + +export const RegisterPendingSkeleton = () => { + const config = useCachedConfig(); + const methods = config ? authMethodsOf(config) : undefined; + const ssoCount = methods ? methods.sso.length : DEFAULT_SSO_COUNT; + + return ( + 0 ? ( + + ) : undefined + } + form={ + (methods?.password ?? true) ? ( + + ) : undefined + } + /> + ); +}; diff --git a/packages/vitnode/src/tanstack/auth/recovery-screen.tsx b/packages/vitnode/src/tanstack/auth/recovery-screen.tsx index b32dfa2130..da4137f974 100644 --- a/packages/vitnode/src/tanstack/auth/recovery-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/recovery-screen.tsx @@ -2,18 +2,13 @@ import { useRouter } from "@tanstack/react-router"; import { useTranslations } from "use-intl"; import { ChangePasswordFormContent } from "@/views/auth/password-reset/change-password-form/change-password-form-content"; -import { PasswordResetFormContent } from "@/views/auth/password-reset/form/password-reset-form-content"; import { PasswordResetContent } from "@/views/auth/password-reset/password-reset-content"; import { ErrorContent } from "@/views/error/error-content"; import type { PasswordResetSearch } from "./recovery"; import { RouteMessages } from "../i18n/route-messages"; -import { - changePasswordFromResetAction, - requestPasswordResetAction, -} from "./actions"; -import { useMiddlewareConfigQuery } from "./middleware-config"; +import { changePasswordFromResetAction } from "./actions"; import { passwordResetMode } from "./recovery"; import { LOGIN_PATH, parseInternalDestination } from "./redirects"; @@ -49,7 +44,6 @@ export const PasswordResetRouteContent = ({ search, }: PasswordResetRouteProps) => { const router = useRouter(); - const { data: config } = useMiddlewareConfigQuery(); const mode = passwordResetMode(search); return ( @@ -63,20 +57,7 @@ export const PasswordResetRouteContent = ({ }} onChangePassword={changePasswordFromResetAction} /> - ) : ( - /* - No `onSuccess` and no navigation: an accepted request swaps the - card for "check your email" and leaves the visitor there. It says - the same thing for an address with an account and one without, - because the API answers the same 201 for both - the - anti-enumeration behaviour is preserved by there being nothing - here that could distinguish them. - */ - - )} + ) : null} ); diff --git a/packages/vitnode/src/tanstack/auth/register-screen.tsx b/packages/vitnode/src/tanstack/auth/register-screen.tsx index d2e810d5d6..8a1c397211 100644 --- a/packages/vitnode/src/tanstack/auth/register-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/register-screen.tsx @@ -29,11 +29,13 @@ export const RegisterRouteContent = () => { } isUnavailable={!methods.signUp} sso={ - + methods.sso.length > 0 ? ( + + ) : undefined } /> diff --git a/packages/vitnode/src/tanstack/auth/sso-route.tsx b/packages/vitnode/src/tanstack/auth/sso-route.tsx index 3c3ca87af2..6f70daa032 100644 --- a/packages/vitnode/src/tanstack/auth/sso-route.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-route.tsx @@ -6,6 +6,7 @@ import { middlewareConfigQueryOptions } from "./middleware-config"; export const SSO_CALLBACK_NAMESPACES = [ "core.global", "core.auth.sso", + "core.auth.reset_password", ] as const; /** diff --git a/packages/vitnode/src/tanstack/auth/sso-screen.tsx b/packages/vitnode/src/tanstack/auth/sso-screen.tsx index 7925fa69b2..ef8464bdcb 100644 --- a/packages/vitnode/src/tanstack/auth/sso-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-screen.tsx @@ -13,7 +13,11 @@ import { useSSOCallback } from "@/views/auth/sso/callback/use-sso-callback"; import { RouteMessages } from "../i18n/route-messages"; import { ssoSettingsHref } from "../sso-connections/route-search"; -import { useCompleteSsoAction, useLinkSsoAction } from "./actions"; +import { + requestPasswordResetAction, + useCompleteSsoAction, + useLinkSsoAction, +} from "./actions"; import { parseSsoCallback } from "./contract"; import { authMethodsOf, @@ -163,8 +167,10 @@ const SsoSignInCallbackRoute = ({ return ( ( +
+ + {label} + +
+); + +export const AuthFrame = ({ + children, + isWide = false, +}: { + children: React.ReactNode; + isWide?: boolean; +}) => ( +
+ {children} +
+); + +const AuthCardFrame = ({ + alternatives, + footer, + form, + heading, + notice, + separatorLabel, +}: { + alternatives?: React.ReactNode; + footer?: React.ReactNode; + form?: React.ReactNode; + heading: React.ReactNode; + notice?: React.ReactNode; + separatorLabel: React.ReactNode; +}) => { + const isSplit = !!alternatives && !!form; + + return ( + + +
+ {heading} + + {notice} + + {isSplit ? ( +
+
+ {alternatives} +
+ +
{form}
+
+ ) : ( + <> + {alternatives ? ( +
{alternatives}
+ ) : null} + {form} + + )} +
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+
+ ); +}; + +export const AuthCard = ({ + description, + title, + ...props +}: { + alternatives?: React.ReactNode; + description: React.ReactNode; + footer?: React.ReactNode; + form?: React.ReactNode; + notice?: React.ReactNode; + title: React.ReactNode; +}) => { + const t = useTranslations("core.global"); + + return ( + +

+ {title} +

+ + {description} + + + } + separatorLabel={t("or")} + {...props} + /> + ); +}; + +export const TextLineSkeleton = ({ + className, + wrapperClassName, +}: { + className?: string; + wrapperClassName?: string; +}) => ( +
+ +
+); + +export const AuthFieldSkeleton = ({ + labelClassName = "w-20", + withLabelRight = false, +}: { + labelClassName?: string; + withLabelRight?: boolean; +}) => ( +
+
+ + {withLabelRight ? : null} +
+ +
+); + +export const AuthCardSkeleton = ({ + alternatives, + form, + label = "Loading", +}: { + alternatives?: React.ReactNode; + form?: React.ReactNode; + label?: string; +}) => ( +
+ + {label} + + + + } + form={form} + heading={ +
+ + +
+ } + separatorLabel={} + /> +
+); diff --git a/packages/vitnode/src/views/auth/auth-link.ts b/packages/vitnode/src/views/auth/auth-link.ts index b13325fbb7..c3ed5e8e13 100644 --- a/packages/vitnode/src/views/auth/auth-link.ts +++ b/packages/vitnode/src/views/auth/auth-link.ts @@ -1,5 +1,4 @@ export const AUTH_HREF = { - resetPassword: "/login/reset-password", signIn: "/login", signUp: "/register", verifyEmail: "/login/verify-email", diff --git a/packages/vitnode/src/views/auth/auth-step.tsx b/packages/vitnode/src/views/auth/auth-step.tsx new file mode 100644 index 0000000000..e0c65e5a18 --- /dev/null +++ b/packages/vitnode/src/views/auth/auth-step.tsx @@ -0,0 +1,18 @@ +import { cn } from "cn"; +import { ViewTransition } from "react"; + +export const AuthStep = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => ( + +
{children}
+
+); diff --git a/packages/vitnode/src/views/auth/last-sign-in-method.ts b/packages/vitnode/src/views/auth/last-sign-in-method.ts new file mode 100644 index 0000000000..2df76d2054 --- /dev/null +++ b/packages/vitnode/src/views/auth/last-sign-in-method.ts @@ -0,0 +1,55 @@ +import React from "react"; + +const STORAGE_KEY = "vitnode_last_sign_in_method"; + +const SSO_PREFIX = "sso:"; + +const listeners = new Set<() => void>(); + +const readStored = (): null | string => { + try { + return globalThis.localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +}; + +const subscribe = (listener: () => void) => { + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) listener(); + }; + + listeners.add(listener); + globalThis.addEventListener("storage", onStorage); + + return () => { + listeners.delete(listener); + globalThis.removeEventListener("storage", onStorage); + }; +}; + +export type SignInMethod = + | "passkey" + | "password" + | `${typeof SSO_PREFIX}${string}`; + +export const ssoSignInMethod = (providerId: string): SignInMethod => + `${SSO_PREFIX}${providerId}`; + +export const ssoProviderOfMethod = ( + method: null | string, +): string | undefined => + method?.startsWith(SSO_PREFIX) ? method.slice(SSO_PREFIX.length) : undefined; + +export const rememberSignInMethod = (method: SignInMethod) => { + try { + globalThis.localStorage.setItem(STORAGE_KEY, method); + } catch { + return; + } + + for (const listener of listeners) listener(); +}; + +export const useLastSignInMethod = (): null | string => + React.useSyncExternalStore(subscribe, readStored, () => null); diff --git a/packages/vitnode/src/views/auth/last-used-badge.tsx b/packages/vitnode/src/views/auth/last-used-badge.tsx new file mode 100644 index 0000000000..e946415ec1 --- /dev/null +++ b/packages/vitnode/src/views/auth/last-used-badge.tsx @@ -0,0 +1,17 @@ +import { useTranslations } from "use-intl"; + +import { Badge } from "@/components/ui/badge"; + +export const LastUsedBadge = ({ id }: { id: string }) => { + const t = useTranslations("core.auth.sign_in"); + + return ( + + {t("last_used")} + + ); +}; diff --git a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx deleted file mode 100644 index 99ec2232ec..0000000000 --- a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import type z from "zod"; - -import { MailCheckIcon, MailIcon } from "lucide-react"; -import { useTranslations } from "use-intl"; - -import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; - -import { AutoForm } from "@/components/form/auto-form"; -import { AutoFormInput } from "@/components/form/fields/input"; -import { - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { InputGroupAddon } from "@/components/ui/input-group"; -import { Label } from "@/components/ui/label"; - -import { - type PasswordResetSubmit, - usePasswordResetForm, -} from "./use-password-reset-form"; - -export type { PasswordResetSubmit }; - -const ConfirmationView = ({ email }: { email: string }) => { - const t = useTranslations("core.auth.reset_password"); - const tSignUp = useTranslations("core.auth.sign_up"); - - return ( - <> - -
- -
- - {t("confirmation.title")} - - - {t("confirmation.desc")} - -
- - - - - - - - {t("confirmation.check_spam")} - - - ); -}; - -export const PasswordResetFormContent = ({ - captcha, - onRequestReset, -}: { - captcha: z.infer["captcha"]; - onRequestReset: PasswordResetSubmit; -}) => { - const { formSchema, onSubmit, sentEmail } = usePasswordResetForm({ - onRequestReset, - }); - const t = useTranslations("core.auth.reset_password"); - const tSignUp = useTranslations("core.auth.sign_up"); - - if (sentEmail) { - return ; - } - - return ( - <> - - -

{t("title")}

-
- {t("desc")} -
- - - ( - - - - - - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - className: "w-full", - children: t("submit"), - }} - /> - - - ); -}; diff --git a/packages/vitnode/src/views/auth/password-reset/form/password-reset-steps.tsx b/packages/vitnode/src/views/auth/password-reset/form/password-reset-steps.tsx new file mode 100644 index 0000000000..d8e51f3489 --- /dev/null +++ b/packages/vitnode/src/views/auth/password-reset/form/password-reset-steps.tsx @@ -0,0 +1,186 @@ +import type z from "zod"; + +import { ArrowLeftIcon, MailCheckIcon, MailIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { Button } from "@/components/ui/button"; +import { InputGroupAddon } from "@/components/ui/input-group"; +import { Spinner } from "@/components/ui/spinner"; + +import type { + PasswordReset, + PasswordResetSubmitValues, +} from "./use-password-reset-form"; + +import { AuthStep } from "../../auth-step"; +import { readRememberedEmail } from "../../remembered-email"; +import { createPasswordResetFormSchema } from "./schema"; + +type Captcha = z.infer["captcha"]; + +const BackButton = ({ onBack }: { onBack: () => void }) => { + const t = useTranslations("core.auth.reset_password"); + + return ( + + ); +}; + +const PasswordResetRequestStep = ({ + captcha, + onBack, + onSend, +}: { + captcha?: Captcha; + onBack: () => void; + onSend: (values: PasswordResetSubmitValues) => Promise; +}) => { + const t = useTranslations("core.auth.reset_password"); + const formSchema = createPasswordResetFormSchema( + { invalidEmail: t("address.invalid") }, + { defaultEmail: readRememberedEmail() }, + ); + + return ( + <> + + +
+

+ {t("title")} +

+

+ {t("desc")} +

+
+ + ( + + + + + + ), + }, + ]} + formSchema={formSchema} + onSubmit={async ({ email }, _form, { captchaToken }) => { + await onSend({ captchaToken, email }); + }} + submitButtonProps={{ + className: "w-full", + children: t("submit"), + }} + /> + + ); +}; + +const PasswordResetSentStep = ({ + email, + onBack, +}: { + email: string; + onBack: () => void; +}) => { + const t = useTranslations("core.auth.reset_password"); + + return ( + <> + + +
+ + + +

+ {t("confirmation.title")} +

+

+ {t("confirmation.desc")}{" "} + + {email} + +

+

+ {t("confirmation.check_spam")} +

+
+ + ); +}; + +export const PasswordResetSteps = ({ + captcha, + reset, +}: { + captcha?: Captcha; + reset: PasswordReset; +}) => + reset.step.kind === "sent" ? ( + + + + ) : ( + + + + ); + +export const PasswordResetTrigger = ({ + children, + isSending, + onClick, +}: { + children: React.ReactNode; + isSending: boolean; + onClick: () => void; +}) => ( + +); diff --git a/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts index bf893120a6..bd99a32e48 100644 --- a/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts +++ b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts @@ -1,20 +1,17 @@ import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; +import { z } from "zod"; -import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; import type { - PasswordResetFormSchema, PasswordResetMutationResult, PasswordResetSubmitValues, } from "./schema"; -import { readRememberedEmail } from "../../remembered-email"; -import { - createPasswordResetFormSchema, - passwordResetFormOutcome, -} from "./schema"; +import { rememberEmail } from "../../remembered-email"; +import { passwordResetFormOutcome } from "./schema"; export type { PasswordResetSubmitValues }; @@ -22,25 +19,35 @@ export type PasswordResetSubmit = ( values: PasswordResetSubmitValues, ) => Promise; -export const usePasswordResetForm = ({ +export type PasswordResetStep = + | { email: string; kind: "sent" } + | { kind: "form" } + | { kind: "idle" }; + +const isEmailAddress = (value: string) => z.email().safeParse(value).success; + +export const usePasswordReset = ({ + captcha, onRequestReset, }: { - onRequestReset: PasswordResetSubmit; + captcha?: z.infer["captcha"]; + onRequestReset?: PasswordResetSubmit; }) => { - const t = useTranslations("core.auth.sign_up"); const tErrors = useTranslations("core.global.errors"); - const [sentEmail, setSentEmail] = React.useState(""); - - const formSchema = createPasswordResetFormSchema( - { invalidEmail: t("email.invalid") }, - { defaultEmail: readRememberedEmail() }, - ); - - const onSubmit: AutoFormOnSubmit = async ( - { email }, - _form, - { captchaToken }, - ) => { + const [step, setStep] = React.useState({ kind: "idle" }); + const [isSending, setIsSending] = React.useState(false); + + const show = (next: PasswordResetStep) => { + React.startTransition(() => { + setStep(next); + }); + }; + + const send = async ({ captchaToken, email }: PasswordResetSubmitValues) => { + if (!onRequestReset) { + throw new Error("A password reset was started without onRequestReset."); + } + const outcome = passwordResetFormOutcome( await onRequestReset({ captchaToken, email }), ); @@ -53,8 +60,38 @@ export const usePasswordResetForm = ({ return; } - setSentEmail(email); + show({ email, kind: "sent" }); }; - return { formSchema, onSubmit, sentEmail }; + const start = async (email: unknown) => { + rememberEmail(email); + const address = typeof email === "string" ? email.trim() : ""; + + if (captcha || !isEmailAddress(address)) { + show({ kind: "form" }); + + return; + } + + setIsSending(true); + + try { + await send({ captchaToken: "", email: address }); + } finally { + setIsSending(false); + } + }; + + return { + back: () => { + show({ kind: "idle" }); + }, + canRequest: !!onRequestReset, + isSending, + send, + start, + step, + }; }; + +export type PasswordReset = ReturnType; diff --git a/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx index 1d0c6c90d8..4560265b17 100644 --- a/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx +++ b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx @@ -1,14 +1,18 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { AUTH_CARD_VIEW_TRANSITION_NAME, AuthFrame } from "../auth-card"; + export const PasswordResetContent = ({ children, }: { children: React.ReactNode; }) => ( -
- {children} -
+ + + {children} + + ); /** Either form's shape while the deployment configuration is still in flight. */ diff --git a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx index fb00dfc11f..4c4d9566ab 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx +++ b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx @@ -1,16 +1,19 @@ +import type { z } from "zod"; + import { Link } from "@tanstack/react-router"; import { LockIcon, MailIcon } from "lucide-react"; import { AnimatePresence, useReducedMotion } from "motion/react"; import * as m from "motion/react-m"; import { useTranslations } from "use-intl"; +import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; + import { AutoForm } from "@/components/form/auto-form"; import { AutoFormInput } from "@/components/form/fields/input"; import { MotionFeatures } from "@/components/motion-features"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { useFormApi } from "@/components/ui/form"; import { InputGroupAddon } from "@/components/ui/input-group"; -import { Skeleton } from "@/components/ui/skeleton"; import { REVEAL_EXIT_TRANSITION, REVEAL_HIDDEN, @@ -21,46 +24,61 @@ import { } from "@/lib/motion"; import { AUTH_HREF } from "../../auth-link"; -import { rememberEmail } from "../../remembered-email"; +import { AuthStep } from "../../auth-step"; +import { + PasswordResetSteps, + PasswordResetTrigger, +} from "../../password-reset/form/password-reset-steps"; +import { + type PasswordReset, + type PasswordResetSubmit, + usePasswordReset, +} from "../../password-reset/form/use-password-reset-form"; import { type SignInSubmit, useSignInForm } from "./use-sign-in-form"; export type { SignInSubmit }; -const ResetPasswordLink = ({ href }: { href: string }) => { +const ResetPasswordButton = ({ reset }: { reset: PasswordReset }) => { const t = useTranslations("core.auth.sign_in"); const { form } = useFormApi(); return ( - { - rememberEmail(form.getFieldValue("email")); + void reset.start(form.getFieldValue("email")); }} - to={href} > {t("password.reset")} - + ); }; export const SignInFormContent = ({ + captcha, + onRequestReset, onSignIn, resendVerificationHref = AUTH_HREF.verifyEmail, - resetPasswordHref = AUTH_HREF.resetPassword, showResetPassword = false, }: { + captcha?: z.infer["captcha"]; + onRequestReset?: PasswordResetSubmit; onSignIn: SignInSubmit; resendVerificationHref?: string; - resetPasswordHref?: string; /** Whether this deployment has an email adapter that can send a reset link. */ showResetPassword?: boolean; }) => { const t = useTranslations("core.auth.sign_in"); const shouldReduceMotion = useReducedMotion(); const { error, formSchema, onSubmit, repeat } = useSignInForm({ onSignIn }); + const reset = usePasswordReset({ captcha, onRequestReset }); + + if (reset.step.kind !== "idle") { + return ; + } return ( -
+ {error && ( @@ -135,8 +153,8 @@ export const SignInFormContent = ({ autoComplete="current-password" label={t("password.label")} labelRight={ - showResetPassword ? ( - + showResetPassword && reset.canRequest ? ( + ) : undefined } type="password" @@ -156,23 +174,6 @@ export const SignInFormContent = ({ children: t("submit"), }} /> -
+ ); }; - -/** The form's shape while the deployment configuration is still in flight. */ -export const SignInFormSkeleton = () => ( -
-
- - -
- -
- - -
- - -
-); diff --git a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-skeleton.tsx b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-skeleton.tsx new file mode 100644 index 0000000000..699680ff69 --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-skeleton.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +import { AuthFieldSkeleton } from "../../auth-card"; + +export const SignInFormSkeleton = ({ + withResetPassword = false, +}: { + withResetPassword?: boolean; +}) => ( +
+ + + +
+); diff --git a/packages/vitnode/src/views/auth/sign-in/passkey/passkey-sign-in-button.tsx b/packages/vitnode/src/views/auth/sign-in/passkey/passkey-sign-in-button.tsx index fa84797a3e..21b3a18e53 100644 --- a/packages/vitnode/src/views/auth/sign-in/passkey/passkey-sign-in-button.tsx +++ b/packages/vitnode/src/views/auth/sign-in/passkey/passkey-sign-in-button.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "use-intl"; import { Button } from "@/components/ui/button"; +import { LastUsedBadge } from "../../last-used-badge"; import { usePasskeySupport } from "../../passkeys/webauthn"; export type PasskeySignInFeedback = @@ -24,9 +25,11 @@ export type PasskeySignInSubmit = () => Promise< >; export const PasskeySignInButton = ({ + isLastUsed = false, onSignIn, showDivider = true, }: { + isLastUsed?: boolean; onSignIn: PasskeySignInSubmit; showDivider?: boolean; }) => { @@ -36,6 +39,7 @@ export const PasskeySignInButton = ({ const isSupported = usePasskeySupport(); const [isPending, setIsPending] = React.useState(false); const hintId = React.useId(); + const lastUsedBadgeId = React.useId(); const showFeedback = (feedback: PasskeySignInFeedback) => { if (feedback === "cancelled") { @@ -81,7 +85,11 @@ export const PasskeySignInButton = ({ ) : null} {isSupported ? null : ( diff --git a/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx b/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx index 4328942ffb..166ba517a9 100644 --- a/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx +++ b/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx @@ -2,8 +2,8 @@ import { Link } from "@tanstack/react-router"; import { useTranslations } from "use-intl"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Card, CardDescription } from "@/components/ui/card"; +import { AuthCard } from "../auth-card"; import { AUTH_HREF } from "../auth-link"; export const SignInContent = ({ @@ -25,42 +25,37 @@ export const SignInContent = ({ const tGlobal = useTranslations("core.global"); return ( -
- -
-
-

- {tGlobal("login")} -

- {t("desc")} -
- - {isUnavailable ? ( - - {t("unavailable.title")} - {t("unavailable.desc")} - - ) : null} - - {sso} - - {form} - - {passkey} -
- - {showSignUp ? ( -
- {t.rich("do_not_have_account", { + + {sso} + {passkey} + + ) : undefined + } + description={t("desc")} + footer={ + showSignUp + ? t.rich("do_not_have_account", { link: text => ( {text} ), - })} -
- ) : null} -
-
+ }) + : undefined + } + form={form} + notice={ + isUnavailable ? ( + + {t("unavailable.title")} + {t("unavailable.desc")} + + ) : undefined + } + title={tGlobal("login")} + /> ); }; diff --git a/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx index d32e489743..971f3bd603 100644 --- a/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx +++ b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx @@ -13,7 +13,6 @@ import { import { AutoFormCheckbox } from "@/components/form/fields/checkbox"; import { AutoFormInput } from "@/components/form/fields/input"; import { InputGroupAddon } from "@/components/ui/input-group"; -import { Skeleton } from "@/components/ui/skeleton"; import { removeSpecialCharacters } from "@/lib/special-characters"; import { PasswordInput } from "../components/password-input"; @@ -135,22 +134,3 @@ export const SignUpFormContent = ({ /> ); }; - -/** The form's shape while the deployment configuration is still in flight. */ -export const SignUpFormSkeleton = () => ( -
- {[0, 1, 2].map(field => ( -
- - -
- ))} - -
- - -
- - -
-); diff --git a/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-skeleton.tsx b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-skeleton.tsx new file mode 100644 index 0000000000..a5bb641def --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-skeleton.tsx @@ -0,0 +1,30 @@ +import { cn } from "cn"; + +import { Skeleton } from "@/components/ui/skeleton"; + +import { AuthFieldSkeleton } from "../../auth-card"; + +const CheckboxCardSkeleton = ({ className }: { className: string }) => ( +
+ +
+ + +
+
+); + +export const SignUpFormSkeleton = ({ + withNewsletter = false, +}: { + withNewsletter?: boolean; +}) => ( +
+ + + + + {withNewsletter ? : null} + +
+); diff --git a/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx index 2a0751013a..2494f16e3e 100644 --- a/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx +++ b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx @@ -2,8 +2,8 @@ import { Link } from "@tanstack/react-router"; import { useTranslations } from "use-intl"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Card, CardDescription } from "@/components/ui/card"; +import { AuthCard } from "../auth-card"; import { AUTH_HREF } from "../auth-link"; import { WrapperSignUp } from "./wrapper"; @@ -22,40 +22,28 @@ export const SignUpContent = ({ const tGlobal = useTranslations("core.global"); return ( -
- - -
-
-

- {tGlobal("register")} -

- {t("desc")} -
- - {isUnavailable ? ( - - {t("unavailable.title")} - {t("unavailable.desc")} - - ) : null} - - {sso} - - {form} -
- -
- {t.rich("already_have_account", { - link: text => ( - - {text} - - ), - })} -
-
-
-
+ + ( + + {text} + + ), + })} + form={form} + notice={ + isUnavailable ? ( + + {t("unavailable.title")} + {t("unavailable.desc")} + + ) : undefined + } + title={tGlobal("register")} + /> + ); }; diff --git a/packages/vitnode/src/views/auth/sign-up/wrapper.tsx b/packages/vitnode/src/views/auth/sign-up/wrapper.tsx index a00db361d1..50df04c39c 100644 --- a/packages/vitnode/src/views/auth/sign-up/wrapper.tsx +++ b/packages/vitnode/src/views/auth/sign-up/wrapper.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { AuthFrame } from "../auth-card"; import { EmailConfirmationView } from "./email-confirmation-view"; const WrapperSignUpContext = React.createContext<{ @@ -20,7 +21,13 @@ export const WrapperSignUp = ({ children }: { children: React.ReactNode }) => { return ( - {sendingEmail ? : children} + {sendingEmail ? ( + + + + ) : ( + children + )} ); }; diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx index c6eb27c1e6..6ccda1a31f 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx @@ -1,13 +1,13 @@ import { cn } from "cn"; -import { useState } from "react"; +import { useId, useState } from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; import type { SSOProvider } from "../providers"; +import { LastUsedBadge } from "../../last-used-badge"; import { ssoBrandColors } from "../brand"; import { SSOProviderIcon } from "./sso-provider-icon"; @@ -31,22 +31,34 @@ const brandStyle = (brandColor: string | undefined) => { }; export const SSOButtonsContent = ({ + lastUsedProviderId, + layout = "grid", onSelectProvider, providers, - showDivider = true, }: { + lastUsedProviderId?: string; + layout?: "grid" | "stack"; onSelectProvider: SSOSelectProvider; providers: readonly SSOProvider[]; - showDivider?: boolean; }) => { const t = useTranslations("core.auth.sso"); const tErrors = useTranslations("core.global.errors"); const [pendingId, setPendingId] = useState(null); + const headingId = useId(); + const lastUsedBadgeId = useId(); if (!providers.length) { return null; } + const lastUsed = providers.find( + provider => provider.id === lastUsedProviderId, + ); + const ordered = lastUsed + ? [lastUsed, ...providers.filter(provider => provider !== lastUsed)] + : providers; + const hasUnpairedLast = (ordered.length - (lastUsed ? 1 : 0)) % 2 === 1; + const failToStart = () => { setPendingId(null); toast.error(tErrors("title"), { @@ -66,48 +78,51 @@ export const SSOButtonsContent = ({ }; return ( - <> -
- {providers.map(provider => ( - - ))} +
+

+ {t("heading")} +

+ +
+ {ordered.map((provider, index) => { + const isLastUsed = provider === lastUsed; + + return ( + + ); + })}
- - {showDivider ? ( -
-
- -
- -
- - {t("or")} - -
-
- ) : null} - +
); }; - -/** The row's shape while the deployment configuration is still in flight. */ -export const SSOButtonsSkeleton = () => ( -
- - -
-); diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-skeleton.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-skeleton.tsx new file mode 100644 index 0000000000..e61115417d --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-skeleton.tsx @@ -0,0 +1,35 @@ +import { cn } from "cn"; + +import { Skeleton } from "@/components/ui/skeleton"; + +import { TextLineSkeleton } from "../../auth-card"; + +export const SSOButtonsSkeleton = ({ + count = 2, + layout = "grid", +}: { + count?: number; + layout?: "grid" | "stack"; +}) => ( +
+ + +
+ {Array.from({ length: count }, (_, index) => ( + + ))} +
+
+); diff --git a/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx index ca63b96f66..81a1d2660d 100644 --- a/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx +++ b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx @@ -1,11 +1,16 @@ +import type { z } from "zod"; + import { Link } from "@tanstack/react-router"; import { useTranslations } from "use-intl"; +import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; + import { Button } from "@/components/ui/button"; import { Card, CardDescription } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { ErrorContent } from "@/views/error/error-content"; +import type { PasswordResetSubmit } from "../../password-reset/form/use-password-reset-form"; import type { SSOLinkSubmit } from "../link/sso-link-form-content"; import type { SSOProvider } from "../providers"; import type { SSOCallbackState } from "./use-sso-callback"; @@ -14,20 +19,22 @@ import { AUTH_HREF } from "../../auth-link"; import { SSOLinkFormContent } from "../link/sso-link-form-content"; export const SSOCallbackContent = ({ + captcha, errorActions, onLink, + onRequestReset, providerId, providers, - resetPasswordHref = AUTH_HREF.resetPassword, showResetPassword = false, signInHref = AUTH_HREF.signIn, state, }: { + captcha?: z.infer["captcha"]; errorActions?: React.ReactNode; onLink: SSOLinkSubmit; + onRequestReset?: PasswordResetSubmit; providerId: string; providers: readonly SSOProvider[]; - resetPasswordHref?: string; showResetPassword?: boolean; signInHref?: string; state: SSOCallbackState; @@ -68,10 +75,11 @@ export const SSOCallbackContent = ({
diff --git a/packages/vitnode/src/views/auth/sso/link/sso-link-form-content.tsx b/packages/vitnode/src/views/auth/sso/link/sso-link-form-content.tsx index af6ce1c3b7..fd12af937b 100644 --- a/packages/vitnode/src/views/auth/sso/link/sso-link-form-content.tsx +++ b/packages/vitnode/src/views/auth/sso/link/sso-link-form-content.tsx @@ -1,8 +1,12 @@ +import type { z } from "zod"; + import { Link } from "@tanstack/react-router"; import { useReducedMotion } from "motion/react"; import * as m from "motion/react-m"; import { useTranslations } from "use-intl"; +import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; + import { AutoForm } from "@/components/form/auto-form"; import { AutoFormInput } from "@/components/form/fields/input"; import { MotionFeatures } from "@/components/motion-features"; @@ -15,6 +19,15 @@ import { SHAKE_KEYFRAMES, SHAKE_TRANSITION } from "@/lib/motion"; import type { SSOLinkOffer } from "./schema"; import { AUTH_HREF } from "../../auth-link"; +import { AuthStep } from "../../auth-step"; +import { + PasswordResetSteps, + PasswordResetTrigger, +} from "../../password-reset/form/password-reset-steps"; +import { + type PasswordResetSubmit, + usePasswordReset, +} from "../../password-reset/form/use-password-reset-form"; import { type SSOLinkSubmit, useSSOLinkForm } from "./use-sso-link-form"; export type { SSOLinkSubmit }; @@ -22,27 +35,35 @@ export type { SSOLinkSubmit }; const EMAIL_FIELD_ID = "sso-link-email"; export const SSOLinkFormContent = ({ + captcha, offer, onLink, + onRequestReset, providerName, - resetPasswordHref = AUTH_HREF.resetPassword, showResetPassword = false, signInHref = AUTH_HREF.signIn, }: { + captcha?: z.infer["captcha"]; offer: SSOLinkOffer; onLink: SSOLinkSubmit; + onRequestReset?: PasswordResetSubmit; providerName: () => React.ReactNode; - resetPasswordHref?: string; showResetPassword?: boolean; signInHref?: string; }) => { const t = useTranslations("core.auth.sso.link"); const shouldReduceMotion = useReducedMotion(); const { error, formSchema, onSubmit } = useSSOLinkForm({ offer, onLink }); + const reset = usePasswordReset({ captcha, onRequestReset }); + const canReset = showResetPassword && reset.canRequest; + + if (reset.step.kind !== "idle") { + return ; + } return ( -
+ {error && ( { + void reset.start(offer.email); + }} > {t("password.reset")} - + ) : undefined } type="password" @@ -98,20 +121,29 @@ export const SSOLinkFormContent = ({ {t.rich("no_password.desc", { provider: providerName })}

- + {canReset ? ( + + ) : ( + + )}
)} - +
); }; diff --git a/plans/community-forum.md b/plans/community-forum.md deleted file mode 100644 index 1b38d190e5..0000000000 --- a/plans/community-forum.md +++ /dev/null @@ -1,224 +0,0 @@ -# Content Engine: container policies, approvals, threads and the Forum plugin - -Status: in progress on canary. Owner: Content Engine + `@vitnode/forum`. - -Stages covered: 3 (container policies and scoped permissions), 4 (approval -workflow and ModCP queue), 5 (threaded content), 8 (Forum plugin MVP), 10 -(performance, SEO, DX). Deferred: reactions (6), subscriptions (7), reports and -ModCP integration of reports for forum content (9). - -## Audit (canary @ 95428da8) - -- Stage 3 is **not** implemented. `resolveContentPublicationPolicy()` returns - `publicWrite.create.status`; containers have no policy storage. -- Scoped moderator permissions exist (moderation MVP): a permission entry may - declare `scope: ""`, grants carry `{ family, mode, ids }`, and - `hasStaffPermission(set, { resource: { family, id } })` decides. Root roles - pass every check. -- A `draft` row never reaches public list, detail, slug, delivery, sitemap, - public search or report capture. Search indexes drafts as private documents. -- Editorial `update` writes the live row; there is no shadow copy. Revisions - are unique per `(type, item, version)` and pruned by retention, so a pending - proposal cannot live in `core_content_revisions`. -- To-one `field.relation({ self: true })` throws when the table is built. -- `publicApi` requires exactly one slug field and forbids user fields. -- Moderator permission modules are written by several registrars that skip a - module already present, so `publicWrite` `can_edit` and the report - permissions cannot coexist on one content type. - -## Stage 3 - container policies - -### Storage - -`core_content_containers.policies jsonb not null default '{}'`: - -```ts -type ContentContainerPolicyOverrides = { - [contentTypeId: string]: Partial; -}; -interface ContentContainerPolicy { - create: "members" | "moderators"; // who may create - publish: "immediate" | "approve"; // new records - edits: "immediate" | "approve"; // author edits of published records - authorEdit: "always" | "never" | "window"; - authorEditMinutes: number; // used by "window" -} -``` - -A missing key means "inherit". An explicit value - including `"never"` - is an -override. The engine never stores `undefined`. - -### Definition - -```ts -defineContentContainer({ - id: "forum.forums", - policies: { - defaults: { - publish: "immediate", - edits: "immediate", - authorEdit: "always", - }, - contentTypes: { - "forum.reply": { authorEdit: "window", authorEditMinutes: 60 }, - }, - }, -}); -``` - -`policies` absent = the container does not participate, and content types in it -keep their pre-Stage-3 behaviour (`publicWrite.create.status`, owners edit -forever, unscoped `can_edit`). - -### Effective policy (pure, `resolveContentContainerPolicy`) - -For every key independently: - -1. the current container's override for the content type; -2. the nearest ancestor's override (walking `parentId`); -3. `definition.policies.contentTypes[contentTypeId]`; -4. `definition.policies.defaults`; -5. the engine fallback: `create: "members"`, `publish` from - `publicWrite.create.status` (`published` -> `immediate`, `draft` -> - `approve`), `edits: "immediate"`, `authorEdit: "always"`, - `authorEditMinutes: 60`. - -### Container of a record - -A content type's container comes from either its own `field.container()` or a -to-one relation marked `field.relation({ target, container: true })`, which -inherits the related record's container (a reply lives in its topic's forum). -The inherited value is never client-writable and is always resolved on the -server from the related row. - -### Scoped permissions - -- Every container definition with `policies` registers a moderation scope - family with the container definition id (`forum.forums`). Resources are - container ids (`"12"`) and explicit subtrees (`"12:tree"`, "this forum and - every forum below it"). A plain id never covers descendants. -- Content types in such a container get moderator permissions under their - `permissionModule`, scoped by that family: `can_edit` (editAny), `can_approve` - (approve and see the queue). Entries merge with the report permissions - instead of replacing them. -- A record in container `C` with ancestors `A1..An` is covered by a grant whose - ids contain `C`, `C:tree` or any `Ai:tree`, or by an unscoped / `mode: "all"` - grant, or by a root role. Being staff alone grants nothing. -- `create: "moderators"` lets only `can_edit` holders in that container create. -- Holders of `can_approve` in the container skip approval for their own posts. - -### Author editing - -`editOwn` is allowed while the record is not hidden and the effective -`authorEdit` allows it: `always`, `never`, or `window` measured from the -record's server `createdAt`. Moderator editing (`can_edit` in scope) is -independent of the window. - -### AdminCP - -`GET/PUT /admin/.../content/{containerModule}/{id}/policies` returns the -overrides, the effective policy per content type and where each value came from; -`PUT` replaces the overrides after strict validation. The container sheet gets a -"Publishing" section per content type with an "Inherit" choice for every key. - -## Stage 4 - approvals - -### Model - -`core_content_submissions` (one row per proposal, never deleted with content): - -| column | notes | -| -------------------------------------- | ------------------------------------------------------------ | -| `id` | serial | -| `pluginId`, `contentTypeId`, `itemId` | target; `itemId` is `contentIdKey` | -| `containerId`, `containerDefinitionId` | scope at submission time, refreshed at decision | -| `kind` | `create` or `edit` | -| `status` | `pending`, `approved`, `rejected`, `superseded`, `cancelled` | -| `values` | proposed values (writable fields only) | -| `baseValues` | the live values the edit was made against | -| `baseVersion` | editorial version the edit was made against | -| `authorId`, `reviewerId` | users, `set null` on delete | -| `reason` | rejection reason | -| `revisionId` | revision written by the approval | -| `createdAt`, `updatedAt`, `decidedAt` | server timestamps | - -Partial unique index: one `pending` row per `(contentTypeId, itemId)`. - -Publication stays `draft | published`. A pending **create** is a `draft` row -plus a `create` submission - nothing public can reach a draft. A pending -**edit** never touches the row; the proposal lives in `values`. - -### Rules - -- The server decides the outcome from the effective policy; the client never - sends a status. -- Author creates in an `approve` container: row stored as draft, submission - `pending`, response carries `approval`. -- Author edits a published record with `edits: "approve"`: validated like a - normal edit, stored as a proposal. A second edit **replaces** the pending - proposal (old row `superseded`). The live row and its version do not change. -- Author edits a record whose create is pending: the draft row is updated in - place, the pending submission's `values` follow it. -- Author edits a record whose create was rejected: the draft row is updated and - a new `create` submission is opened (resubmission). -- Moderator edits always apply immediately. If a proposal is pending its base - version is now stale; approving it answers `409 stale` and the moderator - rejects it (the author may resubmit). -- Approve/reject lock the submission row (`FOR UPDATE`) and require - `status = 'pending'`; a second decision answers `409 decided`. -- Decisions re-resolve the record's **current** container and authorize - `can_approve` against it. A record that is gone cancels the submission - (`409 gone`). -- Approving a create publishes the draft (one `published` event). Approving an - edit runs the editorial update with `expectedVersion = baseVersion` (one - `updated` event, one revision). Rejecting changes nothing on the record. -- Hidden records keep their `hiddenAt`; approving a hidden create publishes it - but it stays hidden. -- Policy changes do not touch pending submissions. - -### Events - -`content.approval.submitted`, `content.approval.approved`, -`content.approval.rejected` with -`{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId?, reason? }`. - -### API (core `moderation` module, `approvals` sub-paths) - -- `GET /moderation/approvals` - scoped queue (page, pageSize, status, contentTypeId, containerId). -- `GET /moderation/approvals/filters` - content types and containers the moderator may review. -- `GET /moderation/approvals/{id}` - proposal, current live values, history. -- `POST /moderation/approvals/{id}/approve`, `POST /moderation/approvals/{id}/reject`. -- `GET /moderation/approvals/mine` - the signed-in author's submissions. - -### UI - -ModCP `/modcp/approvals` (list + detail with compare, approve, reject). - -## Stage 5 - threads - -- To-one self relations are supported. -- `thread: { parent: "", root: "", maxDepth }` - on a content type: the parent must be the same type, in the same root, public, - and above `maxDepth`. The parent is set on create only, so cycles cannot form. -- `GET /{path}/thread?root=&first&after` pages top-level replies (oldest - first, `createdAt, id`) and returns their public descendants. A non-public - ancestor of a public reply is returned as a tombstone (`removed: true`, no - values) so the tree stays intact. -- `field.slug({ generated: true })` gives records without a natural title (a - reply) a server-generated permalink slug. -- `publicApi.people: ["author"]` publishes a user field as a public profile - summary (name, nameCode, avatar, role color/prefix) - an explicit opt-in next - to the strict `fields` allowlist. - -## Stage 8 - Forum plugin (`@vitnode/forum`) - -- `forum.forums` container (policies on), `forum.topic`, `forum.reply`. -- Pages: `/forum`, `/forum/:slug`, `/forum/topics/new`, `/forum/topics/:slug`, - `/forum/topics/:id/edit`. -- Topic stats (public reply count, last activity) from a plugin route. - -## Stage 10 - -Indexes for thread and listing queries, page cache on public routes, canonical -URLs, JSON-LD `DiscussionForumPosting`, sitemap via delivery, docs for every -feature. diff --git a/shots/error-page-new.png b/shots/error-page-new.png deleted file mode 100644 index 0e2d37a3e1..0000000000 Binary files a/shots/error-page-new.png and /dev/null differ