Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/advanced/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 6 additions & 4 deletions apps/web/content/docs/dev/captcha/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn" title="Do not ship this block without both keys">

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/sso/discord.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}

</Step>

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/sso/facebook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}

</Step>

Expand Down
6 changes: 3 additions & 3 deletions apps/web/content/docs/dev/sso/icons.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<ImgDocs
alt="The VitNode login page with a blurple Continue with Discord button, a white Continue with Google button with the four-color G, and a blue Continue with Facebook button above the email form"
alt="The VitNode login page with a Continue with heading over a blurple Discord button, a neutral Google button with the four-color G and a blue Facebook button, next to the email form"
src={loginSsoButtons}
/>

Expand Down
Binary file modified apps/web/content/docs/dev/sso/icons/login-sso-buttons.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 21 additions & 4 deletions apps/web/content/docs/dev/sso/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
9 changes: 9 additions & 0 deletions apps/web/content/docs/ui/view-transitions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ logo in your own layout to join in.
too.
</Callout>

## 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

<Preview name="view-transition-enter-exit" className="*:max-w-full" />
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/locales/@vitnode/core/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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? <link>Zaloguj się</link>.",
Expand Down Expand Up @@ -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 <provider></provider>.",
Expand Down Expand Up @@ -2340,7 +2347,6 @@
"submit": "Połącz i zaloguj się",
"title": "Połącz <provider></provider> ze swoim kontem"
},
"or": "Lub kontynuuj przez e-mail",
"continue_with": "Kontynuuj przez {provider}",
"connection": {
"pending": "Kończymy z {provider}…"
Expand Down
Binary file removed dump.rdb
Binary file not shown.
12 changes: 9 additions & 3 deletions packages/vitnode/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
13 changes: 8 additions & 5 deletions packages/vitnode/src/pages/login/reset-password.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -47,10 +48,12 @@ export const route = defineRoute<PasswordResetRouteData, PasswordResetSearch>({
// 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") }),

Expand Down
8 changes: 6 additions & 2 deletions packages/vitnode/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -121,15 +125,15 @@ export const routes = defineRoutes([
page("/login", {
component: lazy(() => import("./pages/login/index")),
messages: LOGIN_NAMESPACES,
pendingComponent: AuthPendingSkeleton,
pendingComponent: LoginPendingSkeleton,
requires: "guest",
search: normalizeLoginSearch,
}),

page("/register", {
component: lazy(() => import("./pages/register")),
messages: REGISTER_NAMESPACES,
pendingComponent: AuthPendingSkeleton,
pendingComponent: RegisterPendingSkeleton,
requires: "guest",
}),

Expand Down
12 changes: 11 additions & 1 deletion packages/vitnode/src/tanstack/auth/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +102,8 @@ export const usePasskeySignInAction = ({
return result.reason === "access_denied" ? "rejected" : result.reason;
}

rememberSignInMethod("passkey");

removeAdminIdentityQueries(queryClient);
removeUserIdentityQueries(queryClient);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -158,6 +166,8 @@ export const useLinkSsoAction = ({

if (!result.ok) return ssoLinkFormResult(result);

rememberSignInMethod(ssoSignInMethod(providerId));

removeAdminIdentityQueries(queryClient);
removeUserIdentityQueries(queryClient);
await invalidateSession(queryClient);
Expand Down
1 change: 1 addition & 0 deletions packages/vitnode/src/tanstack/auth/login-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
23 changes: 17 additions & 6 deletions packages/vitnode/src/tanstack/auth/login-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
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";
import { SSOButtonsContent } from "@/views/auth/sso/buttons/sso-buttons-content";

import { RouteMessages } from "../i18n/route-messages";
import {
requestPasswordResetAction,
startSsoAction,
usePasskeySignInAction,
useSignInAction,
Expand All @@ -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),
});
Expand All @@ -38,6 +44,8 @@ export const LoginRouteContent = ({ returnTo }: LoginRouteProps) => {
form={
methods.password ? (
<SignInFormContent
captcha={config.captcha}
onRequestReset={requestPasswordResetAction}
onSignIn={signIn}
showResetPassword={methods.resetPassword}
/>
Expand All @@ -47,18 +55,21 @@ export const LoginRouteContent = ({ returnTo }: LoginRouteProps) => {
passkey={
methods.passkey ? (
<PasskeySignInButton
isLastUsed={lastMethod === "passkey"}
onSignIn={passkeySignIn}
showDivider={methods.password || methods.sso.length > 0}
showDivider={false}
/>
) : undefined
}
showSignUp={methods.signUp}
sso={
<SSOButtonsContent
onSelectProvider={startSsoAction}
providers={methods.sso}
showDivider={methods.password}
/>
methods.sso.length > 0 ? (
<SSOButtonsContent
lastUsedProviderId={ssoProviderOfMethod(lastMethod)}
onSelectProvider={startSsoAction}
providers={methods.sso}
/>
) : undefined
}
/>
</RouteMessages>
Expand Down
Loading
Loading