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 (
(
+
)}
-
+
);
};
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