@segmentstream-cloud/app 0.1.0 → 0.3.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.
Files changed (49) hide show
  1. package/README.md +230 -47
  2. package/dist/app.d.ts +42 -5
  3. package/dist/app.js +81 -7
  4. package/dist/config.d.ts +17 -1
  5. package/dist/config.js +29 -7
  6. package/dist/cookies.d.ts +16 -7
  7. package/dist/cookies.js +36 -47
  8. package/dist/data.d.ts +126 -0
  9. package/dist/data.js +206 -0
  10. package/dist/errors.d.ts +36 -0
  11. package/dist/errors.js +66 -0
  12. package/dist/handlers.js +43 -19
  13. package/dist/index.d.ts +21 -7
  14. package/dist/index.js +12 -5
  15. package/dist/me.d.ts +24 -0
  16. package/dist/me.js +33 -0
  17. package/dist/neon.d.ts +92 -0
  18. package/dist/neon.js +414 -0
  19. package/dist/ontology-transport.d.ts +23 -0
  20. package/dist/ontology-transport.js +95 -0
  21. package/dist/ontology.d.ts +217 -0
  22. package/dist/ontology.js +141 -0
  23. package/dist/parameters.d.ts +55 -0
  24. package/dist/parameters.js +118 -0
  25. package/dist/permissions.d.ts +17 -0
  26. package/dist/permissions.js +33 -0
  27. package/dist/proxy.d.ts +2 -0
  28. package/dist/proxy.js +29 -13
  29. package/dist/return-to.d.ts +2 -0
  30. package/dist/return-to.js +24 -0
  31. package/dist/server.d.ts +3 -0
  32. package/dist/server.js +35 -5
  33. package/dist/sign-in.d.ts +5 -1
  34. package/dist/sign-in.js +17 -4
  35. package/dist/tokens.d.ts +22 -3
  36. package/dist/ui/components.d.ts +27 -0
  37. package/dist/ui/components.js +63 -0
  38. package/dist/ui/hooks.d.ts +19 -0
  39. package/dist/ui/hooks.js +56 -0
  40. package/dist/ui/index.d.ts +10 -0
  41. package/dist/ui/index.js +8 -0
  42. package/dist/ui/navigation.d.ts +5 -0
  43. package/dist/ui/navigation.js +8 -0
  44. package/dist/ui/sign-in.d.ts +19 -0
  45. package/dist/ui/sign-in.js +198 -0
  46. package/dist/ui/store.d.ts +61 -0
  47. package/dist/ui/store.js +155 -0
  48. package/dist/ui/styles.css +249 -0
  49. package/package.json +24 -7
@@ -0,0 +1,198 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
4
+ import { safeReturnTo } from "../return-to.js";
5
+ import { useSession } from "./hooks.js";
6
+ import { navigate } from "./navigation.js";
7
+ import { NOT_INVITED, authClient, clearSession, refreshSession } from "./store.js";
8
+ /** Seconds before another code may be requested. */
9
+ const RESEND_AFTER_SECONDS = 30;
10
+ const CODE_LENGTH = 6;
11
+ const noSubscription = () => () => { };
12
+ /** The page's own query string; empty during server rendering. */
13
+ function useSearchParameter(name) {
14
+ const search = useSyncExternalStore(noSubscription, () => window.location.search, () => "");
15
+ return new URLSearchParams(search).get(name);
16
+ }
17
+ /**
18
+ * Email code sign-in with Neon Auth: the email address, then the six-digit code sent to it. New
19
+ * addresses get an account on first sign-in; whether they may use the app is the ontology's
20
+ * decision, which `requireUser()` reports as `not_invited`.
21
+ */
22
+ export function SignIn({ returnTo, error: reason, title = "Sign in" }) {
23
+ const ids = useId();
24
+ const session = useSession();
25
+ const pageReturnTo = useSearchParameter("returnTo");
26
+ const pageError = useSearchParameter("error");
27
+ const [step, setStep] = useState("email");
28
+ const [email, setEmail] = useState("");
29
+ const [code, setCode] = useState("");
30
+ const [pending, setPending] = useState(null);
31
+ const [error, setError] = useState(null);
32
+ const [notice, setNotice] = useState(null);
33
+ /** Seconds until another code may be requested. */
34
+ const [resendIn, setResendIn] = useState(0);
35
+ const [switchingAccount, setSwitchingAccount] = useState(false);
36
+ const codeInput = useRef(null);
37
+ const verifying = useRef(false);
38
+ const resendTimer = useRef(undefined);
39
+ const destination = safeReturnTo(returnTo ?? pageReturnTo);
40
+ const notInvited = (reason ?? pageError) === NOT_INVITED && !switchingAccount;
41
+ useEffect(() => () => clearInterval(resendTimer.current), []);
42
+ useEffect(() => {
43
+ if (step === "code") {
44
+ codeInput.current?.focus();
45
+ }
46
+ }, [step]);
47
+ function holdResend() {
48
+ const deadline = Date.now() + RESEND_AFTER_SECONDS * 1000;
49
+ clearInterval(resendTimer.current);
50
+ setResendIn(RESEND_AFTER_SECONDS);
51
+ resendTimer.current = setInterval(() => {
52
+ const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
53
+ setResendIn(remaining);
54
+ if (remaining === 0) {
55
+ clearInterval(resendTimer.current);
56
+ }
57
+ }, 1000);
58
+ }
59
+ async function sendCode(address) {
60
+ const result = await attempt(() => authClient().emailOtp.sendVerificationOtp({ email: address, type: "sign-in" }));
61
+ if (result !== null) {
62
+ setError(signInErrorMessage(result));
63
+ return false;
64
+ }
65
+ holdResend();
66
+ return true;
67
+ }
68
+ async function submitEmail(event) {
69
+ event.preventDefault();
70
+ const address = email.trim();
71
+ if (address === "") {
72
+ setError("Enter your email address.");
73
+ return;
74
+ }
75
+ setPending("send");
76
+ setError(null);
77
+ setNotice(null);
78
+ if (await sendCode(address)) {
79
+ setEmail(address);
80
+ setCode("");
81
+ setStep("code");
82
+ }
83
+ setPending(null);
84
+ }
85
+ async function verify(value) {
86
+ // The sixth digit and the Enter key may both submit the same code.
87
+ if (verifying.current) {
88
+ return;
89
+ }
90
+ if (!new RegExp(`^[0-9]{${CODE_LENGTH}}$`).test(value)) {
91
+ setError(`Enter the ${CODE_LENGTH}-digit code from the email.`);
92
+ return;
93
+ }
94
+ verifying.current = true;
95
+ setPending("verify");
96
+ setError(null);
97
+ setNotice(null);
98
+ const result = await attempt(() => authClient().signIn.emailOtp({ email, otp: value }));
99
+ if (result !== null) {
100
+ verifying.current = false;
101
+ setError(signInErrorMessage(result));
102
+ setCode("");
103
+ setPending(null);
104
+ codeInput.current?.focus();
105
+ return;
106
+ }
107
+ navigate(destination);
108
+ }
109
+ async function resend() {
110
+ setPending("resend");
111
+ setError(null);
112
+ setNotice(null);
113
+ if (await sendCode(email)) {
114
+ setCode("");
115
+ setNotice(`We sent a new code to ${email}.`);
116
+ }
117
+ setPending(null);
118
+ }
119
+ function changeEmail() {
120
+ setStep("email");
121
+ setCode("");
122
+ setError(null);
123
+ setNotice(null);
124
+ }
125
+ async function switchAccount() {
126
+ setPending("sign-out");
127
+ setError(null);
128
+ await attempt(() => authClient().signOut());
129
+ clearSession();
130
+ await refreshSession();
131
+ setSwitchingAccount(true);
132
+ setStep("email");
133
+ setPending(null);
134
+ }
135
+ const errorId = `${ids}-error`;
136
+ const sessionEmail = session.status === "signed-in" ? (session.user.email ?? session.user.name) : null;
137
+ if (notInvited) {
138
+ return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "Ask for an invitation" }), _jsxs("p", { className: "ss-text", children: [sessionEmail === null ? "You are signed in" : `You are signed in as ${sessionEmail}`, ", but this app has not invited you. Ask your administrator for an invitation, or sign in with another email address."] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), _jsx("button", { type: "button", className: "ss-button", onClick: () => void switchAccount(), disabled: pending !== null, "aria-busy": pending === "sign-out", children: pending === "sign-out" ? "Signing out…" : "Use another email" })] }));
139
+ }
140
+ if (session.status === "signed-in" && step === "email" && pending === null && !switchingAccount) {
141
+ return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "You are signed in" }), _jsx("p", { className: "ss-text", children: sessionEmail === null ? "You already have a session." : `Signed in as ${sessionEmail}.` }), _jsxs("div", { className: "ss-actions", children: [_jsx("a", { className: "ss-button", href: destination, children: "Continue" }), _jsx("button", { type: "button", className: "ss-button ss-button--secondary", onClick: () => void switchAccount(), children: "Use another email" })] })] }));
142
+ }
143
+ if (step === "email") {
144
+ return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: title }), _jsxs("p", { className: "ss-text", children: ["We will email you a ", CODE_LENGTH, "-digit code to sign in."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => void submitEmail(event), noValidate: true, children: [_jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-email`, children: "Email" }), _jsx("input", { id: `${ids}-email`, className: "ss-input", type: "email", name: "email", autoComplete: "email", inputMode: "email", required: true, value: email, onChange: (event) => setEmail(event.target.value), disabled: pending !== null, "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "send", children: pending === "send" ? "Sending code…" : "Send code" })] })] }));
145
+ }
146
+ return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "Check your email" }), _jsxs("p", { className: "ss-text", children: ["Enter the ", CODE_LENGTH, "-digit code we sent to ", _jsx("strong", { children: email }), "."] }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
147
+ event.preventDefault();
148
+ void verify(code);
149
+ }, noValidate: true, children: [_jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-code`, children: "Code" }), _jsx("input", { ref: codeInput, id: `${ids}-code`, className: "ss-input ss-input--code", type: "text", name: "code", inputMode: "numeric", autoComplete: "one-time-code", pattern: "[0-9]*", maxLength: CODE_LENGTH, required: true, value: code, onChange: (event) => {
150
+ const digits = event.target.value.replace(/\D/g, "").slice(0, CODE_LENGTH);
151
+ setCode(digits);
152
+ if (digits.length === CODE_LENGTH) {
153
+ void verify(digits);
154
+ }
155
+ }, disabled: pending === "verify", "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), notice !== null && (_jsx("p", { className: "ss-notice", role: "status", children: notice })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "verify", children: pending === "verify" ? "Signing in…" : "Sign in" })] }), _jsxs("div", { className: "ss-actions", children: [_jsx("button", { type: "button", className: "ss-link", onClick: () => void resend(), disabled: pending !== null || resendIn > 0, "aria-busy": pending === "resend", children: pending === "resend"
156
+ ? "Sending…"
157
+ : resendIn > 0
158
+ ? `Resend code in ${resendIn} s`
159
+ : "Resend code" }), _jsx("button", { type: "button", className: "ss-link", onClick: changeEmail, disabled: pending !== null, children: "Use another email" })] })] }));
160
+ }
161
+ /** Runs a Neon Auth client call; its failure, returned or thrown, or `null` on success. */
162
+ async function attempt(call) {
163
+ try {
164
+ const result = await call();
165
+ return result.error ?? null;
166
+ }
167
+ catch (error) {
168
+ return error ?? new Error("Sign-in failed.");
169
+ }
170
+ }
171
+ /** A sentence for a Neon Auth (Better Auth) failure. */
172
+ export function signInErrorMessage(error) {
173
+ const details = typeof error === "object" && error !== null ? error : {};
174
+ const status = typeof details.status === "number" ? details.status : undefined;
175
+ const text = [details.code, details.message]
176
+ .filter((part) => typeof part === "string")
177
+ .join(" ")
178
+ .toLowerCase();
179
+ if (status === 429 || text.includes("rate limit") || text.includes("too many requests")) {
180
+ return "Too many requests. Wait a minute, then try again.";
181
+ }
182
+ if (text.includes("too many attempts") || text.includes("too_many_attempts")) {
183
+ return "Too many wrong codes. Send a new code.";
184
+ }
185
+ if (text.includes("expired")) {
186
+ return "That code has expired. Send a new one.";
187
+ }
188
+ if (text.includes("otp") || text.includes("code")) {
189
+ return "That code is not right. Check the email and try again.";
190
+ }
191
+ if (text.includes("email")) {
192
+ return "Enter a valid email address.";
193
+ }
194
+ if (status === undefined || status >= 500 || text.includes("network")) {
195
+ return "Sign-in is unavailable right now. Try again in a moment.";
196
+ }
197
+ return "Sign-in failed. Try again.";
198
+ }
@@ -0,0 +1,61 @@
1
+ import { createAuthClient } from "@neondatabase/auth/next";
2
+ import { NOT_INVITED, SegmentStreamApiError } from "../errors.js";
3
+ import type { CurrentUser } from "../me.js";
4
+ /** The Neon Auth API the app serves from `app/src/app/api/auth/[...path]/route.ts`. */
5
+ export declare const AUTH_API_PATH = "/api/auth";
6
+ /** The app's sign-in page. */
7
+ export declare const SIGN_IN_PAGE = "/sign-in";
8
+ export { NOT_INVITED };
9
+ /** The person Neon Auth has signed in, before the ontology is asked about them. */
10
+ export interface SessionUser {
11
+ readonly id: string;
12
+ readonly email: string | null;
13
+ readonly name: string | null;
14
+ }
15
+ export type SessionState = {
16
+ readonly status: "loading";
17
+ } | {
18
+ readonly status: "signed-out";
19
+ } | {
20
+ readonly status: "signed-in";
21
+ readonly sessionId: string;
22
+ readonly user: SessionUser;
23
+ } | {
24
+ readonly status: "unavailable";
25
+ };
26
+ /** `useUser()`: the `/api/ontology/me` answer for the session, loaded once per session. */
27
+ export type UserState = {
28
+ readonly status: "loading";
29
+ readonly user: null;
30
+ readonly error: null;
31
+ } | {
32
+ readonly status: "signed-out";
33
+ readonly user: null;
34
+ readonly error: null;
35
+ } | {
36
+ readonly status: "signed-in";
37
+ readonly user: CurrentUser;
38
+ readonly error: null;
39
+ } | {
40
+ readonly status: "error";
41
+ readonly user: null;
42
+ readonly error: SegmentStreamApiError;
43
+ };
44
+ interface Snapshot {
45
+ readonly session: SessionState;
46
+ /** `/me` answers by Neon Auth session id. */
47
+ readonly users: ReadonlyMap<string, UserState>;
48
+ }
49
+ export declare const LOADING_USER: UserState;
50
+ export declare const SIGNED_OUT_USER: UserState;
51
+ /** The Neon Auth browser client, created on first use. It talks to the app's `/api/auth`. */
52
+ export declare function authClient(): ReturnType<typeof createAuthClient>;
53
+ export declare function subscribe(listener: () => void): () => void;
54
+ export declare function getSnapshot(): Snapshot;
55
+ export declare function getServerSnapshot(): Snapshot;
56
+ /** Reads the session again, after signing in or out. */
57
+ export declare function refreshSession(): Promise<void>;
58
+ /** Forgets the session and every `/me` answer, after signing out. */
59
+ export declare function clearSession(): void;
60
+ /** Starts loading `/me` for a session unless it is loaded or loading. */
61
+ export declare function ensureUser(sessionId: string): void;
@@ -0,0 +1,155 @@
1
+ "use client";
2
+ import { createAuthClient } from "@neondatabase/auth/next";
3
+ import { NOT_INVITED, SegmentStreamApiError, apiErrorFrom, readJson } from "../errors.js";
4
+ import { ME_PATH, parseMe } from "../me.js";
5
+ /** The Neon Auth API the app serves from `app/src/app/api/auth/[...path]/route.ts`. */
6
+ export const AUTH_API_PATH = "/api/auth";
7
+ /** The app's sign-in page. */
8
+ export const SIGN_IN_PAGE = "/sign-in";
9
+ export { NOT_INVITED };
10
+ export const LOADING_USER = { status: "loading", user: null, error: null };
11
+ export const SIGNED_OUT_USER = { status: "signed-out", user: null, error: null };
12
+ const SERVER_SNAPSHOT = { session: { status: "loading" }, users: new Map() };
13
+ let snapshot = SERVER_SNAPSHOT;
14
+ let sessionLoad = null;
15
+ const loadingUsers = new Set();
16
+ const listeners = new Set();
17
+ let client;
18
+ /** The Neon Auth browser client, created on first use. It talks to the app's `/api/auth`. */
19
+ export function authClient() {
20
+ client ??= createAuthClient();
21
+ return client;
22
+ }
23
+ function update(change) {
24
+ snapshot = { ...snapshot, ...change };
25
+ for (const listener of listeners) {
26
+ listener();
27
+ }
28
+ }
29
+ export function subscribe(listener) {
30
+ listeners.add(listener);
31
+ sessionLoad ??= loadSession();
32
+ return () => {
33
+ listeners.delete(listener);
34
+ };
35
+ }
36
+ export function getSnapshot() {
37
+ return snapshot;
38
+ }
39
+ export function getServerSnapshot() {
40
+ return SERVER_SNAPSHOT;
41
+ }
42
+ /** Reads the session again, after signing in or out. */
43
+ export function refreshSession() {
44
+ sessionLoad = loadSession();
45
+ return sessionLoad;
46
+ }
47
+ /** Forgets the session and every `/me` answer, after signing out. */
48
+ export function clearSession() {
49
+ loadingUsers.clear();
50
+ update({ session: { status: "signed-out" }, users: new Map() });
51
+ }
52
+ async function loadSession() {
53
+ let session;
54
+ try {
55
+ const response = await fetch(`${AUTH_API_PATH}/get-session`, {
56
+ credentials: "same-origin",
57
+ cache: "no-store",
58
+ headers: { accept: "application/json" },
59
+ });
60
+ session = response.ok
61
+ ? sessionFrom(await readJson(response))
62
+ : response.status === 401
63
+ ? { status: "signed-out" }
64
+ : { status: "unavailable" };
65
+ }
66
+ catch {
67
+ session = { status: "unavailable" };
68
+ }
69
+ update({ session });
70
+ }
71
+ function sessionFrom(payload) {
72
+ if (typeof payload !== "object" || payload === null) {
73
+ return { status: "signed-out" };
74
+ }
75
+ const { session, user } = payload;
76
+ if (typeof session !== "object" ||
77
+ session === null ||
78
+ typeof user !== "object" ||
79
+ user === null) {
80
+ return { status: "signed-out" };
81
+ }
82
+ const { id: sessionId } = session;
83
+ const { id, email, name } = user;
84
+ if (typeof sessionId !== "string" || typeof id !== "string") {
85
+ return { status: "signed-out" };
86
+ }
87
+ return {
88
+ status: "signed-in",
89
+ sessionId,
90
+ user: {
91
+ id,
92
+ email: typeof email === "string" ? email : null,
93
+ name: typeof name === "string" ? name : null,
94
+ },
95
+ };
96
+ }
97
+ /** Starts loading `/me` for a session unless it is loaded or loading. */
98
+ export function ensureUser(sessionId) {
99
+ if (snapshot.users.has(sessionId) || loadingUsers.has(sessionId)) {
100
+ return;
101
+ }
102
+ loadingUsers.add(sessionId);
103
+ void loadUser().then((state) => {
104
+ if (!loadingUsers.delete(sessionId)) {
105
+ return; // Signed out meanwhile.
106
+ }
107
+ update({ users: new Map(snapshot.users).set(sessionId, state) });
108
+ });
109
+ }
110
+ /** The session's JWT from Neon Auth, then the ontology's account of the user. */
111
+ async function loadUser() {
112
+ try {
113
+ const tokenResponse = await fetch(`${AUTH_API_PATH}/token`, {
114
+ credentials: "same-origin",
115
+ cache: "no-store",
116
+ headers: { accept: "application/json" },
117
+ });
118
+ if (tokenResponse.status === 401) {
119
+ return SIGNED_OUT_USER;
120
+ }
121
+ const tokenPayload = await readJson(tokenResponse);
122
+ const token = typeof tokenPayload === "object" && tokenPayload !== null
123
+ ? tokenPayload.token
124
+ : undefined;
125
+ if (!tokenResponse.ok || typeof token !== "string" || token === "") {
126
+ return failed("auth_unavailable", 503, "Neon Auth did not issue a token. Try again.");
127
+ }
128
+ const response = await fetch(ME_PATH, {
129
+ cache: "no-store",
130
+ headers: { accept: "application/json", authorization: `Bearer ${token}` },
131
+ });
132
+ const payload = await readJson(response);
133
+ if (!response.ok) {
134
+ return {
135
+ status: "error",
136
+ user: null,
137
+ error: apiErrorFrom(response.status, payload, "The ontology service rejected the user."),
138
+ };
139
+ }
140
+ const user = parseMe(payload);
141
+ return user === null
142
+ ? failed("ontology_invalid_response", 502, "GET /api/ontology/me answered an unexpected body.")
143
+ : { status: "signed-in", user, error: null };
144
+ }
145
+ catch (error) {
146
+ return failed("ontology_unreachable", 503, "The app's ontology could not be reached.", error);
147
+ }
148
+ }
149
+ function failed(code, status, detail, cause) {
150
+ return {
151
+ status: "error",
152
+ user: null,
153
+ error: new SegmentStreamApiError({ code, status, detail, retryable: true, cause }),
154
+ };
155
+ }
@@ -0,0 +1,249 @@
1
+ /*
2
+ * @segmentstream-cloud/app/ui: neutral styles for the sign-in form and the user menu.
3
+ * Each rule is one class, so the app's own class selectors override it; the custom properties
4
+ * below change colours, radius and font in one place.
5
+ */
6
+
7
+ :root {
8
+ --ss-font: inherit;
9
+ --ss-text: #111111;
10
+ --ss-muted: #5f5f5f;
11
+ --ss-surface: #ffffff;
12
+ --ss-border: #d4d4d4;
13
+ --ss-accent: #111111;
14
+ --ss-accent-text: #ffffff;
15
+ --ss-danger: #b42318;
16
+ --ss-focus: #2563eb;
17
+ --ss-radius: 0.5rem;
18
+ }
19
+
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --ss-text: #f5f5f5;
23
+ --ss-muted: #a3a3a3;
24
+ --ss-surface: #171717;
25
+ --ss-border: #3f3f3f;
26
+ --ss-accent: #f5f5f5;
27
+ --ss-accent-text: #111111;
28
+ --ss-danger: #f97066;
29
+ --ss-focus: #60a5fa;
30
+ }
31
+ }
32
+
33
+ .ss-card {
34
+ box-sizing: border-box;
35
+ width: min(24rem, 100%);
36
+ margin: 0 auto;
37
+ padding: 2rem;
38
+ border: 1px solid var(--ss-border);
39
+ border-radius: calc(var(--ss-radius) * 2);
40
+ background: var(--ss-surface);
41
+ color: var(--ss-text);
42
+ font-family: var(--ss-font);
43
+ }
44
+
45
+ .ss-title {
46
+ margin: 0 0 0.5rem;
47
+ font-size: 1.5rem;
48
+ line-height: 1.25;
49
+ }
50
+
51
+ .ss-text {
52
+ margin: 0 0 1.25rem;
53
+ color: var(--ss-muted);
54
+ line-height: 1.5;
55
+ }
56
+
57
+ .ss-text strong {
58
+ color: var(--ss-text);
59
+ }
60
+
61
+ .ss-form {
62
+ display: grid;
63
+ gap: 1rem;
64
+ }
65
+
66
+ .ss-field {
67
+ display: grid;
68
+ gap: 0.375rem;
69
+ }
70
+
71
+ .ss-label {
72
+ font-size: 0.875rem;
73
+ font-weight: 600;
74
+ }
75
+
76
+ .ss-input {
77
+ box-sizing: border-box;
78
+ width: 100%;
79
+ padding: 0.625rem 0.75rem;
80
+ border: 1px solid var(--ss-border);
81
+ border-radius: var(--ss-radius);
82
+ background: transparent;
83
+ color: inherit;
84
+ font: inherit;
85
+ }
86
+
87
+ .ss-input--code {
88
+ font-size: 1.5rem;
89
+ font-variant-numeric: tabular-nums;
90
+ letter-spacing: 0.5em;
91
+ text-align: center;
92
+ }
93
+
94
+ .ss-input[aria-invalid="true"] {
95
+ border-color: var(--ss-danger);
96
+ }
97
+
98
+ .ss-input:focus-visible,
99
+ .ss-button:focus-visible,
100
+ .ss-link:focus-visible,
101
+ .ss-user-menu-trigger:focus-visible {
102
+ outline: 2px solid var(--ss-focus);
103
+ outline-offset: 2px;
104
+ }
105
+
106
+ .ss-button {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ box-sizing: border-box;
111
+ padding: 0.625rem 1rem;
112
+ border: 1px solid var(--ss-accent);
113
+ border-radius: var(--ss-radius);
114
+ background: var(--ss-accent);
115
+ color: var(--ss-accent-text);
116
+ font: inherit;
117
+ font-weight: 600;
118
+ text-decoration: none;
119
+ cursor: pointer;
120
+ }
121
+
122
+ .ss-button--secondary {
123
+ background: transparent;
124
+ color: var(--ss-text);
125
+ border-color: var(--ss-border);
126
+ }
127
+
128
+ .ss-button:disabled,
129
+ .ss-link:disabled {
130
+ cursor: not-allowed;
131
+ opacity: 0.6;
132
+ }
133
+
134
+ .ss-link {
135
+ padding: 0;
136
+ border: 0;
137
+ background: none;
138
+ color: var(--ss-text);
139
+ font: inherit;
140
+ font-size: 0.875rem;
141
+ text-decoration: underline;
142
+ text-underline-offset: 0.2em;
143
+ cursor: pointer;
144
+ }
145
+
146
+ .ss-actions {
147
+ display: flex;
148
+ flex-wrap: wrap;
149
+ gap: 1rem;
150
+ align-items: center;
151
+ margin-top: 1.25rem;
152
+ }
153
+
154
+ .ss-error {
155
+ margin: 0;
156
+ color: var(--ss-danger);
157
+ font-size: 0.875rem;
158
+ }
159
+
160
+ .ss-notice {
161
+ margin: 0;
162
+ color: var(--ss-muted);
163
+ font-size: 0.875rem;
164
+ }
165
+
166
+ .ss-user-menu {
167
+ position: relative;
168
+ display: inline-block;
169
+ color: var(--ss-text);
170
+ font-family: var(--ss-font);
171
+ }
172
+
173
+ .ss-user-menu-trigger {
174
+ display: inline-flex;
175
+ gap: 0.5rem;
176
+ align-items: center;
177
+ padding: 0.25rem 0.5rem 0.25rem 0.25rem;
178
+ border: 1px solid var(--ss-border);
179
+ border-radius: 999px;
180
+ cursor: pointer;
181
+ list-style: none;
182
+ }
183
+
184
+ .ss-user-menu-trigger::-webkit-details-marker {
185
+ display: none;
186
+ }
187
+
188
+ .ss-avatar {
189
+ display: inline-grid;
190
+ place-items: center;
191
+ width: 2rem;
192
+ height: 2rem;
193
+ border-radius: 50%;
194
+ background: var(--ss-accent);
195
+ color: var(--ss-accent-text);
196
+ font-size: 0.75rem;
197
+ font-weight: 700;
198
+ }
199
+
200
+ .ss-user-menu-label {
201
+ max-width: 12rem;
202
+ overflow: hidden;
203
+ font-size: 0.875rem;
204
+ text-overflow: ellipsis;
205
+ white-space: nowrap;
206
+ }
207
+
208
+ .ss-user-menu-panel {
209
+ position: absolute;
210
+ right: 0;
211
+ z-index: 10;
212
+ display: grid;
213
+ gap: 0.5rem;
214
+ min-width: 14rem;
215
+ margin-top: 0.5rem;
216
+ padding: 1rem;
217
+ border: 1px solid var(--ss-border);
218
+ border-radius: var(--ss-radius);
219
+ background: var(--ss-surface);
220
+ box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
221
+ }
222
+
223
+ .ss-user-menu-name {
224
+ margin: 0;
225
+ font-weight: 600;
226
+ }
227
+
228
+ .ss-user-menu-email {
229
+ margin: 0;
230
+ color: var(--ss-muted);
231
+ font-size: 0.875rem;
232
+ overflow-wrap: anywhere;
233
+ }
234
+
235
+ .ss-badges {
236
+ display: flex;
237
+ flex-wrap: wrap;
238
+ gap: 0.25rem;
239
+ margin: 0;
240
+ padding: 0;
241
+ list-style: none;
242
+ }
243
+
244
+ .ss-badge {
245
+ padding: 0.125rem 0.5rem;
246
+ border: 1px solid var(--ss-border);
247
+ border-radius: 999px;
248
+ font-size: 0.75rem;
249
+ }