@sjolystinnovation/app-kit 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -43
- package/package.json +11 -2
- package/src/api/index.ts +1 -0
- package/src/api/requestRevalidate.ts +15 -0
- package/src/auth/createAuthOptions.ts +1 -6
- package/src/server/index.ts +4 -0
- package/src/server/publicApi.ts +104 -0
- package/src/server/revalidateRoute.ts +77 -0
- package/src/session/expiry.ts +6 -0
- package/src/session/react/AppSessionProvider.tsx +23 -0
- package/src/session/react/ProtectedGate.tsx +34 -0
- package/src/session/react/RoleGate.tsx +66 -0
- package/src/session/react/SessionExpiryGuard.tsx +294 -0
- package/src/session/react/index.ts +10 -0
- package/src/session/react/sessionExpiryGuardStrings.ts +26 -0
- package/src/styles.css +16 -3
- package/src/toast/index.ts +11 -0
- package/src/ui/Alert.tsx +4 -3
- package/src/ui/CredentialsForm.tsx +113 -0
- package/src/ui/PasswordInput.tsx +11 -29
- package/src/ui/TextInput.tsx +28 -0
- package/src/ui/credentialsFormStrings.ts +29 -0
- package/src/ui/field.tsx +51 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/withDefaults.ts +14 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useEffect, type ReactNode } from "react";
|
|
5
|
+
import { useSessionGate } from "./useSessionGate";
|
|
6
|
+
|
|
7
|
+
export interface ProtectedGateProps {
|
|
8
|
+
/** Where a signed-out visitor, or a session dead on arrival, is sent. */
|
|
9
|
+
loginHref: string;
|
|
10
|
+
/** What shows while the page may not render yet. */
|
|
11
|
+
fallback?: ReactNode | undefined;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders a protected page only on a usable session, and keeps it rendered while the re-sign-in
|
|
17
|
+
* prompt recovers the session. Never redirects while the prompt is open.
|
|
18
|
+
*/
|
|
19
|
+
export function ProtectedGate({ loginHref, fallback = null, children }: ProtectedGateProps) {
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
// A page opened on a dead session must not render, or the user starts work they cannot save.
|
|
25
|
+
// A session that dies later keeps the page, and the prompt recovers it in place.
|
|
26
|
+
if (promptOpen) return;
|
|
27
|
+
if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
|
|
28
|
+
// replace, so Back does not return to a page that only redirects again.
|
|
29
|
+
router.replace(loginHref);
|
|
30
|
+
}
|
|
31
|
+
}, [status, usable, wasUsable, promptOpen, router, loginHref]);
|
|
32
|
+
|
|
33
|
+
return mayRender ? children : fallback;
|
|
34
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
4
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
5
|
+
import { useSessionGate } from "./useSessionGate";
|
|
6
|
+
|
|
7
|
+
interface RoleGateBase {
|
|
8
|
+
/** The role the page needs, as it appears in `session.user.roles`. */
|
|
9
|
+
role: string;
|
|
10
|
+
/** What shows while the session or its roles are not known yet. */
|
|
11
|
+
fallback?: ReactNode | undefined;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Shows `denied` to a signed-in user without the role. */
|
|
16
|
+
interface RoleGateWithDenied extends RoleGateBase {
|
|
17
|
+
denied: ReactNode;
|
|
18
|
+
deniedHref?: never;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Sends a signed-in user without the role to `deniedHref`. */
|
|
22
|
+
interface RoleGateWithDeniedHref extends RoleGateBase {
|
|
23
|
+
deniedHref: string;
|
|
24
|
+
denied?: never;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RoleGateProps = RoleGateWithDenied | RoleGateWithDeniedHref;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders its children only for a user with `role`. Place it inside `ProtectedGate`, which decides
|
|
31
|
+
* whether the session may render at all.
|
|
32
|
+
*
|
|
33
|
+
* While the re-sign-in prompt recovers a signed-out session there is no user, so the roles are
|
|
34
|
+
* unknown. The page stays up only on the path where the session last had the role.
|
|
35
|
+
*/
|
|
36
|
+
export function RoleGate(props: RoleGateProps) {
|
|
37
|
+
const { role, fallback = null, children } = props;
|
|
38
|
+
const { session, recovering, mayRender } = useSessionGate();
|
|
39
|
+
const pathname = usePathname();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
|
|
42
|
+
const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
|
43
|
+
const hasRole = roles.includes(role);
|
|
44
|
+
const rolesKnown = session?.user != null;
|
|
45
|
+
|
|
46
|
+
// The path where the session last had the role. A user without it clears the pass. Set during
|
|
47
|
+
// render, the same way useSessionGate tracks the path it last passed. It starts as undefined, not
|
|
48
|
+
// null, because usePathname can return null.
|
|
49
|
+
const [roleAt, setRoleAt] = useState<string | null | undefined>(undefined);
|
|
50
|
+
if (hasRole && roleAt !== pathname) setRoleAt(pathname);
|
|
51
|
+
if (rolesKnown && !hasRole && roleAt !== undefined) setRoleAt(undefined);
|
|
52
|
+
const keepThroughRecovery = recovering && roleAt === pathname;
|
|
53
|
+
|
|
54
|
+
const allowed = hasRole || keepThroughRecovery;
|
|
55
|
+
const denied = mayRender && rolesKnown && !allowed;
|
|
56
|
+
const deniedHref = "deniedHref" in props ? props.deniedHref : undefined;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
// replace, so Back does not return to a page that only redirects again.
|
|
60
|
+
if (denied && deniedHref != null) router.replace(deniedHref);
|
|
61
|
+
}, [denied, deniedHref, router]);
|
|
62
|
+
|
|
63
|
+
if (mayRender && allowed) return children;
|
|
64
|
+
if (denied && "denied" in props) return props.denied;
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { getSession, signOut, useSession } from "next-auth/react";
|
|
4
|
+
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { Alert } from "../../ui/Alert";
|
|
6
|
+
import { CredentialsForm } from "../../ui/CredentialsForm";
|
|
7
|
+
import { SignInRejected, type CredentialsFormStrings } from "../../ui/credentialsFormStrings";
|
|
8
|
+
import { withDefaults, type TextOverrides } from "../../ui/withDefaults";
|
|
9
|
+
import {
|
|
10
|
+
closeSessionPrompt,
|
|
11
|
+
getSessionPromptOpen,
|
|
12
|
+
isTerminalSessionError,
|
|
13
|
+
openSessionPrompt,
|
|
14
|
+
subscribeSessionPrompt,
|
|
15
|
+
} from "../expiry";
|
|
16
|
+
import { defaultSessionExpiryGuardStrings, type SessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
|
|
17
|
+
|
|
18
|
+
// The absolute expiry cannot be refreshed away, so the banner warns while there is still time.
|
|
19
|
+
const WARN_BEFORE_MS = 30 * 60 * 1000;
|
|
20
|
+
const MINUTE_MS = 60 * 1000;
|
|
21
|
+
// The minutes left at which a screen reader hears the countdown again, after it first appears.
|
|
22
|
+
const ANNOUNCE_AT_MINUTES = [10, 5, 1];
|
|
23
|
+
|
|
24
|
+
// Timers pause while a computer sleeps, so no wait runs longer than this. The banner is then at most
|
|
25
|
+
// this late after a wake. It also keeps every wait far below the browser's 32-bit timer limit.
|
|
26
|
+
const MAX_WAIT_MS = 5 * MINUTE_MS;
|
|
27
|
+
|
|
28
|
+
// How long until the banner text can next change: the warning starts, the minute count drops, or
|
|
29
|
+
// the session expires. Null when nothing more can change.
|
|
30
|
+
function msUntilNextChange(remaining: number): number | null {
|
|
31
|
+
if (remaining <= 0) return null;
|
|
32
|
+
const wait = remaining > WARN_BEFORE_MS ? remaining - WARN_BEFORE_MS : remaining % MINUTE_MS || MINUTE_MS;
|
|
33
|
+
return Math.min(wait, MAX_WAIT_MS);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export interface SessionExpiryGuardProps {
|
|
38
|
+
/** Where a sign-in as a different user is sent after it is signed out. */
|
|
39
|
+
loginHref: string;
|
|
40
|
+
/** Runs after the prompt signs the user back in, such as to show a toast. */
|
|
41
|
+
onRestored?: (() => void) | undefined;
|
|
42
|
+
strings?: TextOverrides<SessionExpiryGuardStrings> | undefined;
|
|
43
|
+
formStrings?: TextOverrides<CredentialsFormStrings> | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type User = { id?: string; email?: string | null };
|
|
47
|
+
type Owner = { id: string; email: string };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Warns before the session's absolute expiry, and signs the user back in without leaving the page
|
|
51
|
+
* when a request finds a dead session. Render it once, inside the SessionProvider.
|
|
52
|
+
*/
|
|
53
|
+
export function SessionExpiryGuard({ loginHref, onRestored, strings, formStrings }: SessionExpiryGuardProps) {
|
|
54
|
+
const text = withDefaults(defaultSessionExpiryGuardStrings, strings);
|
|
55
|
+
const { data: session, status } = useSession();
|
|
56
|
+
const sessionInfo = session as { user?: User; error?: string; absoluteExpiresAt?: number } | null;
|
|
57
|
+
|
|
58
|
+
const open = useSyncExternalStore(subscribeSessionPrompt, getSessionPromptOpen, () => false);
|
|
59
|
+
|
|
60
|
+
// The signed-in user. Latched, because a re-read after a lost cookie reports nobody.
|
|
61
|
+
const [owner, setOwner] = useState<Owner | null>(null);
|
|
62
|
+
const current = sessionInfo?.user;
|
|
63
|
+
// Not while the prompt is open, unless no owner is known yet. A sign-in in the prompt must be
|
|
64
|
+
// checked against the owner, not become it.
|
|
65
|
+
if (current?.id && current.id !== owner?.id && (!open || !owner)) {
|
|
66
|
+
setOwner({ id: current.id, email: current.email ?? "" });
|
|
67
|
+
}
|
|
68
|
+
// The user the session reports now, for the sign-in check when the read-back finds nobody.
|
|
69
|
+
const liveUser = useRef<User | undefined>(undefined);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
liveUser.current = current;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// The minutes left when the countdown banner opened the prompt. Null when a failed request opened
|
|
75
|
+
// it. Kept while the prompt is open, so the wording does not change under the user mid sign-in.
|
|
76
|
+
const [bannerMinutes, setBannerMinutes] = useState<number | null>(null);
|
|
77
|
+
// Cleared when the prompt closes, so a later prompt from a failed request does not reuse it.
|
|
78
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
79
|
+
if (open !== wasOpen) {
|
|
80
|
+
setWasOpen(open);
|
|
81
|
+
if (!open) setBannerMinutes(null);
|
|
82
|
+
}
|
|
83
|
+
// A sign-in is in flight. Closing then would hide its outcome.
|
|
84
|
+
const [pending, setPending] = useState(false);
|
|
85
|
+
|
|
86
|
+
// The clock lives in state, so rendering stays pure. A timer reads it again when the banner can
|
|
87
|
+
// change, and at least every few minutes.
|
|
88
|
+
const [now, setNow] = useState(() => Date.now());
|
|
89
|
+
const remaining =
|
|
90
|
+
status === "authenticated" && sessionInfo?.absoluteExpiresAt != null
|
|
91
|
+
? sessionInfo.absoluteExpiresAt - now
|
|
92
|
+
: null;
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const wait = remaining == null ? null : msUntilNextChange(remaining);
|
|
95
|
+
if (wait == null) return;
|
|
96
|
+
const timer = window.setTimeout(() => setNow(Date.now()), wait);
|
|
97
|
+
return () => window.clearTimeout(timer);
|
|
98
|
+
}, [remaining]);
|
|
99
|
+
// A tab coming back, often after the computer slept, reads the clock again at once.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const onVisible = () => {
|
|
102
|
+
if (document.visibilityState === "visible") setNow(Date.now());
|
|
103
|
+
};
|
|
104
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
105
|
+
return () => document.removeEventListener("visibilitychange", onVisible);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
// Rounded up, so the last half minute reads 1 min, not 0.
|
|
109
|
+
const minutesLeft =
|
|
110
|
+
remaining != null && remaining > 0 && remaining <= WARN_BEFORE_MS ? Math.ceil(remaining / MINUTE_MS) : null;
|
|
111
|
+
const expired =
|
|
112
|
+
status === "authenticated" &&
|
|
113
|
+
(isTerminalSessionError(sessionInfo?.error) || (remaining != null && remaining <= 0));
|
|
114
|
+
|
|
115
|
+
const countdown = (minutes: number | null) => text.expiringSoon.replaceAll("{minutes}", String(minutes));
|
|
116
|
+
|
|
117
|
+
// The minutes a screen reader last heard. Every change of a status region is announced, so it
|
|
118
|
+
// changes only when the countdown starts and when it passes one of ANNOUNCE_AT_MINUTES.
|
|
119
|
+
const [announcedMinutes, setAnnouncedMinutes] = useState<number | null>(null);
|
|
120
|
+
const announceNow =
|
|
121
|
+
minutesLeft == null
|
|
122
|
+
? null
|
|
123
|
+
: announcedMinutes == null || ANNOUNCE_AT_MINUTES.some((at) => minutesLeft <= at && announcedMinutes > at)
|
|
124
|
+
? minutesLeft
|
|
125
|
+
: announcedMinutes;
|
|
126
|
+
if (announceNow !== announcedMinutes) setAnnouncedMinutes(announceNow);
|
|
127
|
+
|
|
128
|
+
// An open prompt stops the gate from redirecting, so the prompt closes when the guard unmounts.
|
|
129
|
+
useEffect(() => closeSessionPrompt, []);
|
|
130
|
+
|
|
131
|
+
// Who the prompt opened for. signIn replaces the session before it resolves, so the live owner
|
|
132
|
+
// would already be the account that just signed in.
|
|
133
|
+
const promptOwner = useRef<Owner | null>(null);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!open) {
|
|
136
|
+
promptOwner.current = null;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Only on the way open. Reassigning while open would adopt the new account.
|
|
140
|
+
promptOwner.current ??= owner;
|
|
141
|
+
}, [open, owner]);
|
|
142
|
+
|
|
143
|
+
const dialog = useRef<HTMLDivElement | null>(null);
|
|
144
|
+
// The keydown handler is bound once per opening, so it reads pending through a ref.
|
|
145
|
+
const pendingRef = useRef(false);
|
|
146
|
+
const onPendingChange = (next: boolean) => {
|
|
147
|
+
pendingRef.current = next;
|
|
148
|
+
setPending(next);
|
|
149
|
+
};
|
|
150
|
+
const titleId = useId();
|
|
151
|
+
|
|
152
|
+
// Escape closes the dialog, except while a sign-in is pending. Tab cycles inside the dialog, even
|
|
153
|
+
// when focus has left it. Focus starts on the password field and returns where it was afterwards.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!open) return;
|
|
156
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
157
|
+
const focusable = () =>
|
|
158
|
+
Array.from(dialog.current?.querySelectorAll<HTMLElement>("input, button:not([disabled])") ?? []);
|
|
159
|
+
|
|
160
|
+
focusable()
|
|
161
|
+
.find((element) => element instanceof HTMLInputElement && element.type === "password")
|
|
162
|
+
?.focus();
|
|
163
|
+
|
|
164
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
165
|
+
if (event.key === "Escape") {
|
|
166
|
+
if (!pendingRef.current) closeSessionPrompt();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (event.key !== "Tab") return;
|
|
170
|
+
const elements = focusable();
|
|
171
|
+
const first = elements[0];
|
|
172
|
+
const last = elements[elements.length - 1];
|
|
173
|
+
if (!first || !last) return;
|
|
174
|
+
const edge = event.shiftKey ? first : last;
|
|
175
|
+
const outside = !dialog.current?.contains(document.activeElement);
|
|
176
|
+
if (outside || document.activeElement === edge) {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
(event.shiftKey ? last : first).focus();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
document.addEventListener("keydown", onKeyDown);
|
|
183
|
+
return () => {
|
|
184
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
185
|
+
// The control that opened the prompt may be gone by now, such as the banner's button.
|
|
186
|
+
if (previous?.isConnected) previous.focus();
|
|
187
|
+
};
|
|
188
|
+
}, [open]);
|
|
189
|
+
|
|
190
|
+
// A session read already in flight can answer after the sign-out and set the cookie again. So
|
|
191
|
+
// the session is checked and the sign-out repeated, and the page is left whatever happens.
|
|
192
|
+
const signOutFully = async () => {
|
|
193
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
194
|
+
try {
|
|
195
|
+
await signOut({ redirect: false });
|
|
196
|
+
if (!(await getSession({ broadcast: false }))) break;
|
|
197
|
+
} catch {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
window.location.assign(loginHref);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const signedIn = async () => {
|
|
205
|
+
// Read the session back, and only report success when it can save.
|
|
206
|
+
let next = (await getSession()) as { user?: User; error?: string } | null;
|
|
207
|
+
// A failed read-back with nobody known yet says nothing about who signed in, so read again.
|
|
208
|
+
for (let attempt = 0; attempt < 2 && !next && !liveUser.current?.id; attempt++) {
|
|
209
|
+
next = (await getSession({ broadcast: false })) as { user?: User; error?: string } | null;
|
|
210
|
+
}
|
|
211
|
+
// The form on the page belongs to whoever opened it. signIn has already replaced the session,
|
|
212
|
+
// so a different account is signed out, even when the read-back failed. The opener's draft
|
|
213
|
+
// stays stored under the opener's id, and comes back when they sign in.
|
|
214
|
+
const opener = promptOwner.current ?? owner;
|
|
215
|
+
// A session without an id counts as another account. When the read-back failed, only a known
|
|
216
|
+
// id counts.
|
|
217
|
+
const signedInId = next ? next.user?.id : liveUser.current?.id;
|
|
218
|
+
if (opener && (next || signedInId) && signedInId !== opener.id) {
|
|
219
|
+
await signOutFully();
|
|
220
|
+
throw new SignInRejected(text.wrongUser);
|
|
221
|
+
}
|
|
222
|
+
if (!next || isTerminalSessionError(next.error)) {
|
|
223
|
+
throw new SignInRejected(text.notRestored);
|
|
224
|
+
}
|
|
225
|
+
closeSessionPrompt();
|
|
226
|
+
onRestored?.();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<>
|
|
231
|
+
{/* Mounted from the start, because a screen reader skips text that arrives with its region. */}
|
|
232
|
+
<div role="status" className="sr-only">
|
|
233
|
+
{!expired && announcedMinutes != null ? countdown(announcedMinutes) : ""}
|
|
234
|
+
</div>
|
|
235
|
+
<div role="alert" className="sr-only">
|
|
236
|
+
{expired ? text.expiredBody : ""}
|
|
237
|
+
</div>
|
|
238
|
+
{(expired || minutesLeft != null) && (
|
|
239
|
+
// At the bottom, because a sticky navbar usually holds the top at the same stacking level.
|
|
240
|
+
<div className="fixed inset-x-0 bottom-0 z-40">
|
|
241
|
+
<Alert variant="warning" role="none">
|
|
242
|
+
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-between gap-2">
|
|
243
|
+
<span>{expired ? text.expiredBody : countdown(minutesLeft)}</span>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => {
|
|
247
|
+
if (!expired) setBannerMinutes(minutesLeft);
|
|
248
|
+
openSessionPrompt();
|
|
249
|
+
}}
|
|
250
|
+
className="font-semibold underline underline-offset-2"
|
|
251
|
+
>
|
|
252
|
+
{text.reSignIn}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</Alert>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{open && (
|
|
260
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dialog-backdrop px-4">
|
|
261
|
+
<div
|
|
262
|
+
ref={dialog}
|
|
263
|
+
role="dialog"
|
|
264
|
+
aria-modal="true"
|
|
265
|
+
aria-labelledby={titleId}
|
|
266
|
+
className="w-full max-w-sm rounded-dialog bg-dialog-bg p-6 shadow-xl"
|
|
267
|
+
>
|
|
268
|
+
<h2 id={titleId} className="text-lg font-bold text-dialog-title">
|
|
269
|
+
{bannerMinutes != null ? text.reSignIn : text.expiredTitle}
|
|
270
|
+
</h2>
|
|
271
|
+
<p className="mt-1 mb-4 text-sm text-dialog-text">
|
|
272
|
+
{bannerMinutes != null ? countdown(bannerMinutes) : text.expiredBody}
|
|
273
|
+
</p>
|
|
274
|
+
<CredentialsForm
|
|
275
|
+
initialEmail={owner?.email ?? ""}
|
|
276
|
+
onSignedIn={signedIn}
|
|
277
|
+
strings={formStrings}
|
|
278
|
+
onPendingChange={onPendingChange}
|
|
279
|
+
>
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={closeSessionPrompt}
|
|
283
|
+
disabled={pending}
|
|
284
|
+
className="rounded-button border border-button-secondary-border px-4 text-sm font-medium text-button-secondary-text hover:bg-button-secondary-hover-bg"
|
|
285
|
+
>
|
|
286
|
+
{text.close}
|
|
287
|
+
</button>
|
|
288
|
+
</CredentialsForm>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -1,2 +1,12 @@
|
|
|
1
|
+
export { ProtectedGate } from "./ProtectedGate";
|
|
2
|
+
export type { ProtectedGateProps } from "./ProtectedGate";
|
|
3
|
+
export { SessionExpiryGuard } from "./SessionExpiryGuard";
|
|
4
|
+
export type { SessionExpiryGuardProps } from "./SessionExpiryGuard";
|
|
5
|
+
export { defaultSessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
|
|
6
|
+
export type { SessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
|
|
1
7
|
export { useSessionGate } from "./useSessionGate";
|
|
2
8
|
export type { SessionGate } from "./useSessionGate";
|
|
9
|
+
export { AppSessionProvider } from "./AppSessionProvider";
|
|
10
|
+
export type { AppSessionProviderProps } from "./AppSessionProvider";
|
|
11
|
+
export { RoleGate } from "./RoleGate";
|
|
12
|
+
export type { RoleGateProps } from "./RoleGate";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Plain values, with no "use client", so a server component can import them.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The guard's text. Pass the app's own language. The defaults are English. Plain strings, so a
|
|
5
|
+
* server component can pass them.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionExpiryGuardStrings {
|
|
8
|
+
expiredTitle: string;
|
|
9
|
+
expiredBody: string;
|
|
10
|
+
/** Every `{minutes}` is replaced with the minutes left. */
|
|
11
|
+
expiringSoon: string;
|
|
12
|
+
reSignIn: string;
|
|
13
|
+
notRestored: string;
|
|
14
|
+
wrongUser: string;
|
|
15
|
+
close: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const defaultSessionExpiryGuardStrings: SessionExpiryGuardStrings = {
|
|
19
|
+
expiredTitle: "Session expired",
|
|
20
|
+
expiredBody: "Your session has expired. Sign in again to save.",
|
|
21
|
+
expiringSoon: "Your session expires in {minutes} min. Sign in again to continue without interruption.",
|
|
22
|
+
reSignIn: "Sign in again",
|
|
23
|
+
notRestored: "We could not renew the session. Try again.",
|
|
24
|
+
wrongUser: "This page belongs to a different user. Sign in with the same account.",
|
|
25
|
+
close: "Close",
|
|
26
|
+
};
|
package/src/styles.css
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
@source ".";
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
* Design tokens for the components. The defaults are stock Tailwind values, except the focus ring
|
|
6
|
-
* which
|
|
7
|
-
* selector such as .dark for a scoped theme.
|
|
5
|
+
* Design tokens for the components. The defaults are stock Tailwind values, except the focus ring
|
|
6
|
+
* and the primary button, which use the theme's --color-primary and --color-primary-foreground. Set
|
|
7
|
+
* a token in the app's own @theme to restyle, or in a selector such as .dark for a scoped theme.
|
|
8
8
|
*/
|
|
9
9
|
@theme default {
|
|
10
10
|
--radius-alert: var(--radius-lg);
|
|
@@ -40,4 +40,17 @@
|
|
|
40
40
|
--input-focus-ring-width: 2px;
|
|
41
41
|
--color-input-toggle: var(--color-gray-400);
|
|
42
42
|
--color-input-toggle-hover: var(--color-gray-600);
|
|
43
|
+
|
|
44
|
+
--radius-button: var(--radius-lg);
|
|
45
|
+
--color-button-primary-bg: var(--color-primary);
|
|
46
|
+
--color-button-primary-text: var(--color-primary-foreground);
|
|
47
|
+
--color-button-secondary-border: var(--color-gray-300);
|
|
48
|
+
--color-button-secondary-text: var(--color-gray-700);
|
|
49
|
+
--color-button-secondary-hover-bg: var(--color-gray-50);
|
|
50
|
+
|
|
51
|
+
--radius-dialog: var(--radius-xl);
|
|
52
|
+
--color-dialog-backdrop: color-mix(in oklab, var(--color-black) 40%, transparent);
|
|
53
|
+
--color-dialog-bg: var(--color-white);
|
|
54
|
+
--color-dialog-title: var(--color-gray-900);
|
|
55
|
+
--color-dialog-text: var(--color-gray-600);
|
|
43
56
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { toast as sonnerToast } from "sonner";
|
|
2
|
+
import { getSessionPromptOpen } from "../session/expiry";
|
|
3
|
+
|
|
4
|
+
// While the re-sign-in prompt is open it already tells the user why a request failed, so error
|
|
5
|
+
// toasts stay quiet. Every other toast passes through.
|
|
6
|
+
const error: typeof sonnerToast.error = (...args) => (getSessionPromptOpen() ? "" : sonnerToast.error(...args));
|
|
7
|
+
|
|
8
|
+
/** sonner's `toast`, except that error toasts stay quiet while the re-sign-in prompt is open. */
|
|
9
|
+
export const toast: typeof sonnerToast = new Proxy(sonnerToast, {
|
|
10
|
+
get: (target, property, receiver) => (property === "error" ? error : Reflect.get(target, property, receiver)),
|
|
11
|
+
});
|
package/src/ui/Alert.tsx
CHANGED
|
@@ -4,10 +4,11 @@ import { cx } from "./cx";
|
|
|
4
4
|
export interface AlertProps {
|
|
5
5
|
variant?: "error" | "success" | "info" | "warning";
|
|
6
6
|
/**
|
|
7
|
-
* Defaults to alert, which a screen reader interrupts for.
|
|
8
|
-
*
|
|
7
|
+
* Defaults to alert, which a screen reader interrupts for. A screen reader announces status
|
|
8
|
+
* without interrupting, on every change of its text. Use none when a separate live region
|
|
9
|
+
* announces the text.
|
|
9
10
|
*/
|
|
10
|
-
role?: "alert" | "status";
|
|
11
|
+
role?: "alert" | "status" | "none";
|
|
11
12
|
/**
|
|
12
13
|
* Classes for placing the alert, such as a margin. Restyle it through the theme variables. A
|
|
13
14
|
* class that clashes with the alert's own wins or loses by the order Tailwind emits them in.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { signIn } from "next-auth/react";
|
|
4
|
+
import { useState, type FormEvent, type ReactNode } from "react";
|
|
5
|
+
import { SIGN_IN_ERRORS } from "../session/expiry";
|
|
6
|
+
import { Alert } from "./Alert";
|
|
7
|
+
import { PasswordInput } from "./PasswordInput";
|
|
8
|
+
import {
|
|
9
|
+
SignInRejected,
|
|
10
|
+
defaultCredentialsFormStrings,
|
|
11
|
+
type CredentialsFormStrings,
|
|
12
|
+
} from "./credentialsFormStrings";
|
|
13
|
+
import { TextInput } from "./TextInput";
|
|
14
|
+
import { withDefaults, type TextOverrides } from "./withDefaults";
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export interface CredentialsFormProps {
|
|
18
|
+
initialEmail?: string | undefined;
|
|
19
|
+
/** Runs after next-auth accepts the credentials. Throw `SignInRejected` to refuse with a reason. */
|
|
20
|
+
onSignedIn: () => void | Promise<void>;
|
|
21
|
+
/** Extra buttons next to the submit button, such as a close button. */
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
strings?: TextOverrides<CredentialsFormStrings> | undefined;
|
|
24
|
+
/** Runs when a sign-in starts and when it ends, such as to stop a dialog closing mid-request. */
|
|
25
|
+
onPendingChange?: ((pending: boolean) => void) | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// next-auth answers a stale CSRF token with its own sign-in page and ?csrf=true.
|
|
29
|
+
function refusedForCsrf(url: string | null | undefined): boolean {
|
|
30
|
+
if (!url) return false;
|
|
31
|
+
const parsed = new URL(url, window.location.origin);
|
|
32
|
+
return parsed.pathname.endsWith("/signin") && parsed.searchParams.get("csrf") === "true";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Email and password sign-in through next-auth's credentials provider. */
|
|
36
|
+
export function CredentialsForm({
|
|
37
|
+
initialEmail = "",
|
|
38
|
+
onSignedIn,
|
|
39
|
+
children,
|
|
40
|
+
strings,
|
|
41
|
+
onPendingChange,
|
|
42
|
+
}: CredentialsFormProps) {
|
|
43
|
+
const text = withDefaults(defaultCredentialsFormStrings, strings);
|
|
44
|
+
const [email, setEmail] = useState(initialEmail);
|
|
45
|
+
// Follows initialEmail until the user types, so an email learned after mount still fills in.
|
|
46
|
+
const [emailTyped, setEmailTyped] = useState(false);
|
|
47
|
+
if (!emailTyped && email !== initialEmail) setEmail(initialEmail);
|
|
48
|
+
const [password, setPassword] = useState("");
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
const [submitting, setSubmitting] = useState(false);
|
|
51
|
+
|
|
52
|
+
const handleSubmit = async (event: FormEvent) => {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
setError(null);
|
|
55
|
+
setSubmitting(true);
|
|
56
|
+
onPendingChange?.(true);
|
|
57
|
+
try {
|
|
58
|
+
const result = await signIn("credentials", { email, password, redirect: false });
|
|
59
|
+
if (result?.error) {
|
|
60
|
+
setError(result.error === SIGN_IN_ERRORS.unavailable ? text.signInUnavailable : text.invalidCredentials);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// next-auth reports a stale CSRF token as a success, see refusedForCsrf.
|
|
64
|
+
if (!result?.ok || refusedForCsrf(result.url)) {
|
|
65
|
+
setError(text.somethingWentWrong);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await onSignedIn();
|
|
69
|
+
setPassword("");
|
|
70
|
+
} catch (caught) {
|
|
71
|
+
setError(caught instanceof SignInRejected ? caught.message : text.somethingWentWrong);
|
|
72
|
+
} finally {
|
|
73
|
+
setSubmitting(false);
|
|
74
|
+
onPendingChange?.(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
80
|
+
{error && <Alert variant="error">{error}</Alert>}
|
|
81
|
+
<TextInput
|
|
82
|
+
label={text.email}
|
|
83
|
+
name="email"
|
|
84
|
+
type="email"
|
|
85
|
+
value={email}
|
|
86
|
+
onChange={(e) => {
|
|
87
|
+
setEmailTyped(true);
|
|
88
|
+
setEmail(e.target.value);
|
|
89
|
+
}}
|
|
90
|
+
required
|
|
91
|
+
/>
|
|
92
|
+
<PasswordInput
|
|
93
|
+
label={text.password}
|
|
94
|
+
name="password"
|
|
95
|
+
value={password}
|
|
96
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
97
|
+
required
|
|
98
|
+
showPasswordLabel={text.showPassword}
|
|
99
|
+
hidePasswordLabel={text.hidePassword}
|
|
100
|
+
/>
|
|
101
|
+
<div className="flex gap-2">
|
|
102
|
+
<button
|
|
103
|
+
type="submit"
|
|
104
|
+
disabled={submitting}
|
|
105
|
+
className="flex-1 rounded-button bg-button-primary-bg text-button-primary-text font-semibold py-2.5 hover:brightness-95 disabled:opacity-60 transition"
|
|
106
|
+
>
|
|
107
|
+
{submitting ? text.signingIn : text.signIn}
|
|
108
|
+
</button>
|
|
109
|
+
{children}
|
|
110
|
+
</div>
|
|
111
|
+
</form>
|
|
112
|
+
);
|
|
113
|
+
}
|