authfyio-react 0.3.9
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 +83 -0
- package/dist/client.d.ts +128 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +227 -0
- package/dist/components.d.ts +79 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +111 -0
- package/dist/hooks-flow.d.ts +59 -0
- package/dist/hooks-flow.d.ts.map +1 -0
- package/dist/hooks-flow.js +133 -0
- package/dist/hooks.d.ts +113 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +212 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/jwt.d.ts +20 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +30 -0
- package/dist/provider.d.ts +50 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +91 -0
- package/dist/ui/CheckoutButton.d.ts +14 -0
- package/dist/ui/CheckoutButton.d.ts.map +1 -0
- package/dist/ui/CheckoutButton.js +28 -0
- package/dist/ui/Control.d.ts +17 -0
- package/dist/ui/Control.d.ts.map +1 -0
- package/dist/ui/Control.js +33 -0
- package/dist/ui/CreateOrganization.d.ts +19 -0
- package/dist/ui/CreateOrganization.d.ts.map +1 -0
- package/dist/ui/CreateOrganization.js +75 -0
- package/dist/ui/OrganizationList.d.ts +13 -0
- package/dist/ui/OrganizationList.d.ts.map +1 -0
- package/dist/ui/OrganizationList.js +47 -0
- package/dist/ui/OrganizationProfile.d.ts +12 -0
- package/dist/ui/OrganizationProfile.d.ts.map +1 -0
- package/dist/ui/OrganizationProfile.js +116 -0
- package/dist/ui/OrganizationSwitcher.d.ts +17 -0
- package/dist/ui/OrganizationSwitcher.d.ts.map +1 -0
- package/dist/ui/OrganizationSwitcher.js +59 -0
- package/dist/ui/PricingTable.d.ts +15 -0
- package/dist/ui/PricingTable.d.ts.map +1 -0
- package/dist/ui/PricingTable.js +74 -0
- package/dist/ui/SignIn.d.ts +23 -0
- package/dist/ui/SignIn.d.ts.map +1 -0
- package/dist/ui/SignIn.js +489 -0
- package/dist/ui/SignUp.d.ts +18 -0
- package/dist/ui/SignUp.d.ts.map +1 -0
- package/dist/ui/SignUp.js +153 -0
- package/dist/ui/UnstyledButtons.d.ts +24 -0
- package/dist/ui/UnstyledButtons.d.ts.map +1 -0
- package/dist/ui/UnstyledButtons.js +42 -0
- package/dist/ui/UserAvatar.d.ts +14 -0
- package/dist/ui/UserAvatar.d.ts.map +1 -0
- package/dist/ui/UserAvatar.js +52 -0
- package/dist/ui/UserButton.d.ts +15 -0
- package/dist/ui/UserButton.d.ts.map +1 -0
- package/dist/ui/UserButton.js +82 -0
- package/dist/ui/UserProfile.d.ts +19 -0
- package/dist/ui/UserProfile.d.ts.map +1 -0
- package/dist/ui/UserProfile.js +199 -0
- package/dist/ui/index.d.ts +14 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +13 -0
- package/dist/ui/styles.d.ts +10 -0
- package/dist/ui/styles.d.ts.map +1 -0
- package/dist/ui/styles.js +291 -0
- package/package.json +46 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useAuthfyio } from '../provider.js';
|
|
5
|
+
import { ensureStylesInjected } from './styles.js';
|
|
6
|
+
const SOCIAL_LABELS = {
|
|
7
|
+
google: 'Google',
|
|
8
|
+
github: 'GitHub',
|
|
9
|
+
facebook: 'Facebook',
|
|
10
|
+
apple: 'Apple',
|
|
11
|
+
microsoft: 'Microsoft',
|
|
12
|
+
linkedin: 'LinkedIn',
|
|
13
|
+
discord: 'Discord',
|
|
14
|
+
slack: 'Slack',
|
|
15
|
+
twitter: 'X / Twitter',
|
|
16
|
+
};
|
|
17
|
+
const WALLET_LABELS = {
|
|
18
|
+
metamask: 'MetaMask',
|
|
19
|
+
coinbase_wallet: 'Coinbase Wallet',
|
|
20
|
+
okx_wallet: 'OKX Wallet',
|
|
21
|
+
solana: 'Solana',
|
|
22
|
+
base: 'Base',
|
|
23
|
+
};
|
|
24
|
+
const WALLET_COLORS = {
|
|
25
|
+
metamask: '#F6851B',
|
|
26
|
+
coinbase_wallet: '#0052FF',
|
|
27
|
+
okx_wallet: '#000000',
|
|
28
|
+
solana: '#9945FF',
|
|
29
|
+
base: '#0052FF',
|
|
30
|
+
};
|
|
31
|
+
export function SignUp({ redirectUrl = '/', signInUrl = '/sign-in', title = 'Create your account', subtitle = 'One account for everything.', showBranding = true, brandText = 'Authfyio', socialProviders: socialOverride, onSignUp, }) {
|
|
32
|
+
useEffect(ensureStylesInjected, []);
|
|
33
|
+
const { client } = useAuthfyio();
|
|
34
|
+
const baseUrl = client.baseUrl;
|
|
35
|
+
const [config, setConfig] = useState(null);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
client.fetchAuthConfig().then((c) => {
|
|
39
|
+
if (cancelled)
|
|
40
|
+
return;
|
|
41
|
+
setConfig(c);
|
|
42
|
+
});
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true;
|
|
45
|
+
};
|
|
46
|
+
}, [client]);
|
|
47
|
+
// Mirror SignIn — pk + return_url query on every OAuth anchor so the
|
|
48
|
+
// top-level navigation lands cookies on this app's origin via the
|
|
49
|
+
// /api/af/oauth-finish bridge. See SignIn for the full rationale.
|
|
50
|
+
const oauthQuery = (() => {
|
|
51
|
+
if (typeof window === 'undefined')
|
|
52
|
+
return '';
|
|
53
|
+
const params = new URLSearchParams();
|
|
54
|
+
if (client.publishableKey)
|
|
55
|
+
params.set('publishable_key', client.publishableKey);
|
|
56
|
+
const finish = new URL('/api/af/oauth-finish', window.location.origin);
|
|
57
|
+
finish.searchParams.set('to', redirectUrl);
|
|
58
|
+
params.set('return_url', finish.toString());
|
|
59
|
+
return '?' + params.toString();
|
|
60
|
+
})();
|
|
61
|
+
const enabledSocial = (socialOverride ?? (config?.socialProviders ?? [])).filter((p) => SOCIAL_LABELS[p]);
|
|
62
|
+
const enabledWallets = (config?.web3Wallets ?? []).filter((w) => WALLET_LABELS[w]);
|
|
63
|
+
const legal = config?.legal;
|
|
64
|
+
const brand = config?.branding;
|
|
65
|
+
const cardStyle = brand?.primaryColor
|
|
66
|
+
? { ['--af-primary']: brand.primaryColor }
|
|
67
|
+
: {};
|
|
68
|
+
const effectiveBrandText = brand?.appName ?? brandText;
|
|
69
|
+
const showPoweredBy = brand && !brand.removeBranding;
|
|
70
|
+
const su = config?.signUp;
|
|
71
|
+
const signUpEnabled = su?.enabled !== false;
|
|
72
|
+
// What fields/identifiers the form actually needs based on the
|
|
73
|
+
// dashboard config. We need at least one identifier (email, username,
|
|
74
|
+
// or phone) plus a credential (password) — otherwise there's nothing
|
|
75
|
+
// to submit.
|
|
76
|
+
const showEmail = !!su?.email;
|
|
77
|
+
const showUsername = !!su?.username;
|
|
78
|
+
const usernameRequired = !!su?.username?.required;
|
|
79
|
+
const showPhone = !!su?.phone;
|
|
80
|
+
const showPassword = !!su?.password;
|
|
81
|
+
const showFirstAndLastName = !!su?.firstAndLastName;
|
|
82
|
+
const requireFirstAndLastName = !!su?.firstAndLastName?.required;
|
|
83
|
+
const hasSomething = showEmail || showUsername || showPhone || enabledSocial.length > 0 || enabledWallets.length > 0;
|
|
84
|
+
const [email, setEmail] = useState('');
|
|
85
|
+
const [username, setUsername] = useState('');
|
|
86
|
+
const [phone, setPhone] = useState('');
|
|
87
|
+
const [password, setPassword] = useState('');
|
|
88
|
+
const [firstName, setFirstName] = useState('');
|
|
89
|
+
const [lastName, setLastName] = useState('');
|
|
90
|
+
const [loading, setLoading] = useState(false);
|
|
91
|
+
const [error, setError] = useState(null);
|
|
92
|
+
async function submit(e) {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
setLoading(true);
|
|
95
|
+
setError(null);
|
|
96
|
+
try {
|
|
97
|
+
const body = {};
|
|
98
|
+
if (showEmail && email)
|
|
99
|
+
body.email = email;
|
|
100
|
+
if (showUsername && username)
|
|
101
|
+
body.username = username;
|
|
102
|
+
if (showPhone && phone)
|
|
103
|
+
body.phone = phone;
|
|
104
|
+
if (showPassword && password)
|
|
105
|
+
body.password = password;
|
|
106
|
+
if (showFirstAndLastName && firstName)
|
|
107
|
+
body.firstName = firstName;
|
|
108
|
+
if (showFirstAndLastName && lastName)
|
|
109
|
+
body.lastName = lastName;
|
|
110
|
+
// Use email-password endpoint when there's an email; otherwise the
|
|
111
|
+
// matching identifier-specific endpoint. (Username/phone-only
|
|
112
|
+
// sign-up endpoints are 0.3 work — for now require email or
|
|
113
|
+
// social.)
|
|
114
|
+
const endpoint = showEmail
|
|
115
|
+
? '/v1/auth/sign-up/email-password'
|
|
116
|
+
: showUsername
|
|
117
|
+
? '/v1/auth/sign-up/username-password'
|
|
118
|
+
: '/v1/auth/sign-up/email-password';
|
|
119
|
+
const res = await fetch(`${baseUrl}${endpoint}`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
credentials: 'include',
|
|
122
|
+
headers: { 'content-type': 'application/json' },
|
|
123
|
+
body: JSON.stringify(body),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const b = await res.json().catch(() => ({}));
|
|
127
|
+
throw new Error(b?.message ?? 'Sign-up failed.');
|
|
128
|
+
}
|
|
129
|
+
const ok = (await res.json());
|
|
130
|
+
if (onSignUp)
|
|
131
|
+
onSignUp(ok);
|
|
132
|
+
if (typeof window !== 'undefined')
|
|
133
|
+
window.location.href = redirectUrl;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
setError(err?.message ?? 'Sign-up failed.');
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
setLoading(false);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return (_jsxs("div", { className: "af-card", role: "form", "aria-label": "Sign up", style: cardStyle, children: [showBranding && (_jsxs("div", { className: "af-brand", children: [brand?.logoUrl ? (_jsx("img", { src: brand.logoUrl, alt: "", style: { width: 26, height: 26, borderRadius: 8, objectFit: 'cover' } })) : (_jsx("span", { className: "af-brand-mark", "aria-hidden": true })), _jsx("span", { className: "af-brand-text", children: effectiveBrandText })] })), _jsx("h1", { className: "af-title", children: title }), _jsx("p", { className: "af-subtitle", children: subtitle }), !signUpEnabled ? (_jsx("div", { className: "af-error", children: "Sign-up is disabled for this app. Ask the app owner for an invite." })) : (_jsxs(_Fragment, { children: [(enabledSocial.length > 0 || enabledWallets.length > 0) && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "af-social-grid", children: [enabledSocial.map((p) => (_jsxs("a", { className: "af-social-btn", href: `${baseUrl}/v1/auth/oauth/${p}/authorize${oauthQuery}`, "aria-label": `Sign up with ${SOCIAL_LABELS[p]}`, children: ["Continue with ", SOCIAL_LABELS[p]] }, p))), enabledWallets.map((w) => (_jsxs("a", { className: "af-social-btn", href: `${baseUrl}/v1/auth/web3/${w}/authorize${oauthQuery}`, "aria-label": `Sign up with ${WALLET_LABELS[w]}`, children: [_jsx("span", { className: "af-social-icon", "aria-hidden": true, style: {
|
|
143
|
+
display: 'inline-flex',
|
|
144
|
+
alignItems: 'center',
|
|
145
|
+
justifyContent: 'center',
|
|
146
|
+
background: WALLET_COLORS[w] ?? '#6b7280',
|
|
147
|
+
color: 'white',
|
|
148
|
+
borderRadius: 4,
|
|
149
|
+
fontSize: 9,
|
|
150
|
+
fontWeight: 700,
|
|
151
|
+
textTransform: 'uppercase',
|
|
152
|
+
}, children: (WALLET_LABELS[w] ?? w).slice(0, 1) }), "Continue with ", WALLET_LABELS[w]] }, w)))] }), (showEmail || showUsername || showPhone) && _jsx("div", { className: "af-divider", children: "or" })] })), (showEmail || showUsername || showPhone) && (_jsxs("form", { onSubmit: submit, children: [showEmail && (_jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", htmlFor: "af-signup-email", children: "Email" }), _jsx("input", { id: "af-signup-email", className: "af-input", type: "email", autoComplete: "email", required: su?.emailRequired ?? true, value: email, onChange: (e) => setEmail(e.target.value), placeholder: "you@company.com" })] })), showUsername && (_jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", htmlFor: "af-signup-username", children: "Username" }), _jsx("input", { id: "af-signup-username", className: "af-input", type: "text", autoComplete: "username", required: usernameRequired, minLength: su?.username?.minLength ?? 4, maxLength: su?.username?.maxLength ?? 64, value: username, onChange: (e) => setUsername(e.target.value), placeholder: "yourname" })] })), showPhone && (_jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", htmlFor: "af-signup-phone", children: "Phone" }), _jsx("input", { id: "af-signup-phone", className: "af-input", type: "tel", autoComplete: "tel", value: phone, onChange: (e) => setPhone(e.target.value), placeholder: "+14155551234" })] })), showFirstAndLastName && (_jsxs("div", { style: { display: 'flex', gap: 8 }, children: [_jsxs("div", { className: "af-field", style: { flex: 1 }, children: [_jsx("label", { className: "af-label", htmlFor: "af-signup-first", children: "First name" }), _jsx("input", { id: "af-signup-first", className: "af-input", type: "text", autoComplete: "given-name", required: requireFirstAndLastName, value: firstName, onChange: (e) => setFirstName(e.target.value) })] }), _jsxs("div", { className: "af-field", style: { flex: 1 }, children: [_jsx("label", { className: "af-label", htmlFor: "af-signup-last", children: "Last name" }), _jsx("input", { id: "af-signup-last", className: "af-input", type: "text", autoComplete: "family-name", required: requireFirstAndLastName, value: lastName, onChange: (e) => setLastName(e.target.value) })] })] })), showPassword && (_jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", htmlFor: "af-signup-password", children: "Password" }), _jsx("input", { id: "af-signup-password", className: "af-input", type: "password", autoComplete: "new-password", required: true, minLength: 8, value: password, onChange: (e) => setPassword(e.target.value), placeholder: "At least 8 characters" })] })), _jsx("div", { className: "af-primary-row", children: _jsx("button", { type: "submit", className: "af-btn af-btn-primary", disabled: loading, children: loading ? 'Creating account…' : 'Create account' }) })] })), config && !hasSomething && (_jsx("div", { className: "af-error", children: "No sign-up methods are enabled for this app. Enable at least one identifier (email, username, phone, or a social provider) in the Authfyio dashboard." }))] })), error && _jsx("div", { className: "af-error", children: error }), (legal?.termsOfServiceUrl || legal?.privacyPolicyUrl) && (_jsxs("div", { className: "af-legal", children: ["By creating an account you agree to our", ' ', legal?.termsOfServiceUrl && (_jsxs(_Fragment, { children: [_jsx("a", { href: legal.termsOfServiceUrl, target: "_blank", rel: "noreferrer", children: "Terms" }), legal?.privacyPolicyUrl ? ' and ' : '.'] })), legal?.privacyPolicyUrl && (_jsxs(_Fragment, { children: [_jsx("a", { href: legal.privacyPolicyUrl, target: "_blank", rel: "noreferrer", children: "Privacy Policy" }), "."] }))] })), signUpEnabled && (_jsxs("div", { className: "af-footer", children: ["Already have an account? ", _jsx("a", { href: signInUrl, children: "Sign in" })] })), showPoweredBy && (_jsxs("div", { className: "af-powered", children: ["Powered by ", _jsx("a", { href: "https://authfyio.com", target: "_blank", rel: "noreferrer", children: "Authfyio" })] }))] }));
|
|
153
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Unstyled button wrappers — they inherit your app's own styling. Use them
|
|
4
|
+
* when you want sign-in / sign-up / sign-out behavior without the prebuilt
|
|
5
|
+
* card UI: <SignInButton />, <SignUpButton />, <SignOutButton />.
|
|
6
|
+
*/
|
|
7
|
+
type ButtonProps = {
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Path to your own sign-in / sign-up page. Default '/sign-in' / '/sign-up'. */
|
|
11
|
+
url?: string;
|
|
12
|
+
/** Preserve current URL as a redirect_url query param (default true). */
|
|
13
|
+
preserveReturnPath?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function SignInButton({ children, className, url, preserveReturnPath }: ButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export declare function SignUpButton({ children, className, url, preserveReturnPath }: ButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
type SignOutProps = {
|
|
18
|
+
children?: React.ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
redirectUrl?: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function UnstyledSignOutButton({ children, className, redirectUrl }: SignOutProps): import("react/jsx-runtime").JSX.Element;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=UnstyledButtons.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UnstyledButtons.d.ts","sourceRoot":"","sources":["../../src/ui/UnstyledButtons.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B;;;;GAIG;AAEH,KAAK,WAAW,GAAG;IACjB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF,wBAAgB,YAAY,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAgB,EAAE,kBAAyB,EAAE,EAAE,WAAW,2CAU7G;AAED,wBAAgB,YAAY,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAgB,EAAE,kBAAyB,EAAE,EAAE,WAAW,2CAU7G;AAED,KAAK,YAAY,GAAG;IAClB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,wBAAgB,qBAAqB,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAiB,EAAE,EAAE,YAAY,2CAe7F"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useAuthfyio } from '../provider.js';
|
|
4
|
+
export function SignInButton({ children, className, url = '/sign-in', preserveReturnPath = true }) {
|
|
5
|
+
return (_jsx("a", { href: buildHref(url, preserveReturnPath), className: className, style: className ? undefined : inlineLinkStyle, children: children ?? 'Sign in' }));
|
|
6
|
+
}
|
|
7
|
+
export function SignUpButton({ children, className, url = '/sign-up', preserveReturnPath = true }) {
|
|
8
|
+
return (_jsx("a", { href: buildHref(url, preserveReturnPath), className: className, style: className ? undefined : inlineLinkStyle, children: children ?? 'Sign up' }));
|
|
9
|
+
}
|
|
10
|
+
export function UnstyledSignOutButton({ children, className, redirectUrl = '/' }) {
|
|
11
|
+
const { client } = useAuthfyio();
|
|
12
|
+
const baseUrl = client.baseUrl;
|
|
13
|
+
async function onClick() {
|
|
14
|
+
try {
|
|
15
|
+
await fetch(`${baseUrl}/v1/auth/sign-out`, { method: 'POST', credentials: 'include' });
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
if (typeof window !== 'undefined')
|
|
19
|
+
window.location.href = redirectUrl;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return (_jsx("button", { type: "button", onClick: onClick, className: className, style: className ? undefined : inlineButtonStyle, children: children ?? 'Sign out' }));
|
|
23
|
+
}
|
|
24
|
+
function buildHref(path, preserve) {
|
|
25
|
+
if (!preserve || typeof window === 'undefined')
|
|
26
|
+
return path;
|
|
27
|
+
const ret = window.location.pathname + window.location.search;
|
|
28
|
+
if (!ret || ret === '/')
|
|
29
|
+
return path;
|
|
30
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
31
|
+
return `${path}${sep}redirect_url=${encodeURIComponent(ret)}`;
|
|
32
|
+
}
|
|
33
|
+
const inlineLinkStyle = { color: 'inherit', textDecoration: 'underline' };
|
|
34
|
+
const inlineButtonStyle = {
|
|
35
|
+
font: 'inherit',
|
|
36
|
+
background: 'transparent',
|
|
37
|
+
border: 0,
|
|
38
|
+
padding: 0,
|
|
39
|
+
cursor: 'pointer',
|
|
40
|
+
color: 'inherit',
|
|
41
|
+
textDecoration: 'underline',
|
|
42
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type UserAvatarProps = {
|
|
2
|
+
/** Avatar edge length in px. Default 28. */
|
|
3
|
+
size?: number;
|
|
4
|
+
className?: string;
|
|
5
|
+
/** Render only the fallback initials (skip the Gravatar network call). */
|
|
6
|
+
disableImage?: boolean;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Standalone avatar — just the circle, no dropdown. Gravatar with an
|
|
10
|
+
* identicon fallback; renders the user's initials while the image is
|
|
11
|
+
* loading and when no image is available.
|
|
12
|
+
*/
|
|
13
|
+
export declare function UserAvatar({ size, className, disableImage }: UserAvatarProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
//# sourceMappingURL=UserAvatar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UserAvatar.d.ts","sourceRoot":"","sources":["../../src/ui/UserAvatar.tsx"],"names":[],"mappings":"AAOA,MAAM,MAAM,eAAe,GAAG;IAC5B,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0EAA0E;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EAAE,IAAS,EAAE,SAAS,EAAE,YAAY,EAAE,EAAE,eAAe,2CA0CjF"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useUser } from '../hooks.js';
|
|
5
|
+
import { ensureStylesInjected } from './styles.js';
|
|
6
|
+
/**
|
|
7
|
+
* Standalone avatar — just the circle, no dropdown. Gravatar with an
|
|
8
|
+
* identicon fallback; renders the user's initials while the image is
|
|
9
|
+
* loading and when no image is available.
|
|
10
|
+
*/
|
|
11
|
+
export function UserAvatar({ size = 28, className, disableImage }) {
|
|
12
|
+
useEffect(ensureStylesInjected, []);
|
|
13
|
+
const { user } = useUser();
|
|
14
|
+
const email = user?.email ?? '';
|
|
15
|
+
const displayName = user?.firstName
|
|
16
|
+
? `${user.firstName}${user.lastName ? ' ' + user.lastName : ''}`
|
|
17
|
+
: email.split('@')[0] || 'U';
|
|
18
|
+
const initials = getInitials(displayName, email);
|
|
19
|
+
const fontSize = Math.round(size * 0.4);
|
|
20
|
+
return (_jsxs("span", { className: className, style: {
|
|
21
|
+
width: size,
|
|
22
|
+
height: size,
|
|
23
|
+
borderRadius: '999px',
|
|
24
|
+
display: 'inline-flex',
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
justifyContent: 'center',
|
|
27
|
+
overflow: 'hidden',
|
|
28
|
+
position: 'relative',
|
|
29
|
+
background: 'rgba(17,24,39,0.06)',
|
|
30
|
+
color: '#4b5563',
|
|
31
|
+
fontSize,
|
|
32
|
+
fontWeight: 600,
|
|
33
|
+
}, "aria-label": `Avatar for ${displayName}`, children: [!disableImage && email && (
|
|
34
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
35
|
+
_jsx("img", { src: gravatar(email, size * 2), alt: "", width: size, height: size, style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' } })), _jsx("span", { style: { position: 'relative' }, children: initials })] }));
|
|
36
|
+
}
|
|
37
|
+
function getInitials(name, email) {
|
|
38
|
+
const src = (name && name.trim()) || email || '?';
|
|
39
|
+
const parts = src.trim().split(/\s+/);
|
|
40
|
+
if (parts.length >= 2)
|
|
41
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
42
|
+
return parts[0].slice(0, 2).toUpperCase();
|
|
43
|
+
}
|
|
44
|
+
function gravatar(email, size) {
|
|
45
|
+
// Cheap stable hash — good enough for deterministic identicon fallbacks.
|
|
46
|
+
let h = 5381;
|
|
47
|
+
const norm = email.trim().toLowerCase();
|
|
48
|
+
for (let i = 0; i < norm.length; i++)
|
|
49
|
+
h = ((h << 5) + h + norm.charCodeAt(i)) | 0;
|
|
50
|
+
const hex = (h >>> 0).toString(16).padStart(8, '0');
|
|
51
|
+
return `https://www.gravatar.com/avatar/${hex.repeat(4)}?s=${size}&d=identicon`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type UserButtonProps = {
|
|
2
|
+
/** Redirect target after sign-out. Default '/'. */
|
|
3
|
+
afterSignOutUrl?: string;
|
|
4
|
+
/** Path to the user profile page (linked from the dropdown). Default '/user'. */
|
|
5
|
+
userProfileUrl?: string;
|
|
6
|
+
/** Show email under the name in the menu head. Default true. */
|
|
7
|
+
showEmail?: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Avatar button that opens a dropdown with "Manage account" and "Sign out".
|
|
11
|
+
* Invisible when signed out — wrap in
|
|
12
|
+
* <SignedIn> or guard yourself.
|
|
13
|
+
*/
|
|
14
|
+
export declare function UserButton({ afterSignOutUrl, userProfileUrl, showEmail, }: UserButtonProps): import("react/jsx-runtime").JSX.Element | null;
|
|
15
|
+
//# sourceMappingURL=UserButton.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UserButton.d.ts","sourceRoot":"","sources":["../../src/ui/UserButton.tsx"],"names":[],"mappings":"AAQA,MAAM,MAAM,eAAe,GAAG;IAC5B,mDAAmD;IACnD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iFAAiF;IACjF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACzB,eAAqB,EACrB,cAAwB,EACxB,SAAgB,GACjB,EAAE,eAAe,kDAyFjB"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useAuthfyio } from '../provider.js';
|
|
5
|
+
import { useAuth, useUser } from '../hooks.js';
|
|
6
|
+
import { ensureStylesInjected } from './styles.js';
|
|
7
|
+
/**
|
|
8
|
+
* Avatar button that opens a dropdown with "Manage account" and "Sign out".
|
|
9
|
+
* Invisible when signed out — wrap in
|
|
10
|
+
* <SignedIn> or guard yourself.
|
|
11
|
+
*/
|
|
12
|
+
export function UserButton({ afterSignOutUrl = '/', userProfileUrl = '/user', showEmail = true, }) {
|
|
13
|
+
useEffect(ensureStylesInjected, []);
|
|
14
|
+
const { client } = useAuthfyio();
|
|
15
|
+
const baseUrl = client.baseUrl;
|
|
16
|
+
const auth = useAuth();
|
|
17
|
+
const { user } = useUser();
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
const rootRef = useRef(null);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!open)
|
|
22
|
+
return;
|
|
23
|
+
function onDocClick(e) {
|
|
24
|
+
if (!rootRef.current)
|
|
25
|
+
return;
|
|
26
|
+
if (!rootRef.current.contains(e.target))
|
|
27
|
+
setOpen(false);
|
|
28
|
+
}
|
|
29
|
+
function onKey(e) {
|
|
30
|
+
if (e.key === 'Escape')
|
|
31
|
+
setOpen(false);
|
|
32
|
+
}
|
|
33
|
+
window.addEventListener('mousedown', onDocClick);
|
|
34
|
+
window.addEventListener('keydown', onKey);
|
|
35
|
+
return () => {
|
|
36
|
+
window.removeEventListener('mousedown', onDocClick);
|
|
37
|
+
window.removeEventListener('keydown', onKey);
|
|
38
|
+
};
|
|
39
|
+
}, [open]);
|
|
40
|
+
async function signOut() {
|
|
41
|
+
try {
|
|
42
|
+
await fetch(`${baseUrl}/v1/auth/sign-out`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
credentials: 'include',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
if (typeof window !== 'undefined')
|
|
49
|
+
window.location.href = afterSignOutUrl;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!auth.isSignedIn)
|
|
53
|
+
return null;
|
|
54
|
+
const email = user?.email ?? '';
|
|
55
|
+
const displayName = user?.firstName
|
|
56
|
+
? `${user.firstName}${user.lastName ? ' ' + user.lastName : ''}`
|
|
57
|
+
: email.split('@')[0] || 'User';
|
|
58
|
+
const initials = getInitials(displayName, email);
|
|
59
|
+
const avatar = email ? gravatar(email) : null;
|
|
60
|
+
return (_jsxs("div", { ref: rootRef, style: { position: 'relative', display: 'inline-block' }, children: [_jsxs("button", { type: "button", className: "af-user-btn", onClick: () => setOpen((v) => !v), "aria-haspopup": "menu", "aria-expanded": open, children: [_jsxs("span", { className: "af-avatar", "aria-hidden": true, children: [avatar && _jsx("img", { src: avatar, alt: "" }), !avatar && initials] }), _jsx("span", { children: displayName })] }), open && (_jsxs("div", { className: "af-menu", role: "menu", children: [_jsxs("div", { className: "af-menu-head", children: [_jsxs("span", { className: "af-avatar af-avatar-lg", "aria-hidden": true, style: { width: 40, height: 40, fontSize: 12 }, children: [avatar && _jsx("img", { src: avatar, alt: "" }), !avatar && initials] }), _jsxs("div", { style: { minWidth: 0 }, children: [_jsx("div", { className: "af-menu-head-name", children: displayName }), showEmail && email && _jsx("div", { className: "af-menu-head-email", children: email })] })] }), _jsx("a", { href: userProfileUrl, className: "af-menu-item", role: "menuitem", children: "Manage account" }), _jsx("div", { className: "af-menu-sep" }), _jsx("button", { type: "button", className: "af-menu-item af-menu-item-danger", role: "menuitem", onClick: signOut, children: "Sign out" })] }))] }));
|
|
61
|
+
}
|
|
62
|
+
function getInitials(name, email) {
|
|
63
|
+
const src = (name && name.trim()) || email || '?';
|
|
64
|
+
const parts = src.trim().split(/\s+/);
|
|
65
|
+
if (parts.length >= 2)
|
|
66
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
67
|
+
return parts[0].slice(0, 2).toUpperCase();
|
|
68
|
+
}
|
|
69
|
+
function gravatar(email) {
|
|
70
|
+
// md5 of the lowercased, trimmed email — computed at request time in the
|
|
71
|
+
// browser via SubtleCrypto where available, else we fall back to a simple
|
|
72
|
+
// hash (not cryptographically correct for Gravatar, but keeps us from
|
|
73
|
+
// bundling md5). We use Gravatar's "identicon" fallback so everyone gets
|
|
74
|
+
// a unique-looking avatar even without a registered profile.
|
|
75
|
+
const normalized = email.trim().toLowerCase();
|
|
76
|
+
// Tiny djb2 hash → hex; collides like crazy but good enough for fallbacks.
|
|
77
|
+
let h = 5381;
|
|
78
|
+
for (let i = 0; i < normalized.length; i++)
|
|
79
|
+
h = ((h << 5) + h + normalized.charCodeAt(i)) | 0;
|
|
80
|
+
const hex = (h >>> 0).toString(16).padStart(8, '0');
|
|
81
|
+
return `https://www.gravatar.com/avatar/${hex.repeat(4)}?s=80&d=identicon`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type Tab = 'profile' | 'security' | 'sessions';
|
|
2
|
+
export type UserProfileProps = {
|
|
3
|
+
/** Default open tab. */
|
|
4
|
+
initialTab?: Tab;
|
|
5
|
+
/** Called after the user saves profile changes. */
|
|
6
|
+
onUpdate?: (user: {
|
|
7
|
+
firstName: string | null;
|
|
8
|
+
lastName: string | null;
|
|
9
|
+
}) => void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Self-service user profile card. Tabs: Profile (name/email), Security
|
|
13
|
+
* (password change, future MFA), Sessions (future). Minimal scaffolding —
|
|
14
|
+
* uses the public instance API endpoints available today, with room for
|
|
15
|
+
* future expansion.
|
|
16
|
+
*/
|
|
17
|
+
export declare function UserProfile({ initialTab, onUpdate }: UserProfileProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=UserProfile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UserProfile.d.ts","sourceRoot":"","sources":["../../src/ui/UserProfile.tsx"],"names":[],"mappings":"AAQA,KAAK,GAAG,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,CAAC;AAE/C,MAAM,MAAM,gBAAgB,GAAG;IAC7B,wBAAwB;IACxB,UAAU,CAAC,EAAE,GAAG,CAAC;IACjB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,KAAK,IAAI,CAAC;CAClF,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,UAAsB,EAAE,QAAQ,EAAE,EAAE,gBAAgB,2CA2DjF"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useAuthfyio } from '../provider.js';
|
|
5
|
+
import { useUser } from '../hooks.js';
|
|
6
|
+
import { ensureStylesInjected } from './styles.js';
|
|
7
|
+
/**
|
|
8
|
+
* Self-service user profile card. Tabs: Profile (name/email), Security
|
|
9
|
+
* (password change, future MFA), Sessions (future). Minimal scaffolding —
|
|
10
|
+
* uses the public instance API endpoints available today, with room for
|
|
11
|
+
* future expansion.
|
|
12
|
+
*/
|
|
13
|
+
export function UserProfile({ initialTab = 'profile', onUpdate }) {
|
|
14
|
+
useEffect(ensureStylesInjected, []);
|
|
15
|
+
const { client } = useAuthfyio();
|
|
16
|
+
const baseUrl = client.baseUrl;
|
|
17
|
+
const { user, isLoaded } = useUser();
|
|
18
|
+
const [tab, setTab] = useState(initialTab);
|
|
19
|
+
if (!isLoaded) {
|
|
20
|
+
return (_jsx("div", { className: "af-card", style: { maxWidth: 720 }, children: _jsx("div", { className: "af-subtitle", children: "Loading\u2026" }) }));
|
|
21
|
+
}
|
|
22
|
+
if (!user) {
|
|
23
|
+
return (_jsx("div", { className: "af-card", style: { maxWidth: 720 }, children: _jsx("div", { className: "af-error", children: "You are not signed in." }) }));
|
|
24
|
+
}
|
|
25
|
+
return (_jsxs("div", { className: "af-card", style: { maxWidth: 720 }, children: [_jsx("h1", { className: "af-title", children: "Account" }), _jsx("p", { className: "af-subtitle", children: "Manage your profile, credentials, and active sessions." }), _jsxs("div", { className: "af-profile", children: [_jsxs("div", { className: "af-profile-nav", role: "tablist", children: [_jsx("button", { role: "tab", className: `af-profile-nav-item${tab === 'profile' ? ' is-active' : ''}`, onClick: () => setTab('profile'), children: "Profile" }), _jsx("button", { role: "tab", className: `af-profile-nav-item${tab === 'security' ? ' is-active' : ''}`, onClick: () => setTab('security'), children: "Security" }), _jsx("button", { role: "tab", className: `af-profile-nav-item${tab === 'sessions' ? ' is-active' : ''}`, onClick: () => setTab('sessions'), children: "Sessions" })] }), _jsxs("div", { className: "af-profile-panel", role: "tabpanel", children: [tab === 'profile' && _jsx(ProfileTab, { baseUrl: baseUrl, user: user, onUpdate: onUpdate }), tab === 'security' && _jsx(SecurityTab, { baseUrl: baseUrl }), tab === 'sessions' && _jsx(SessionsTab, { baseUrl: baseUrl })] })] })] }));
|
|
26
|
+
}
|
|
27
|
+
function ProfileTab({ baseUrl, user, onUpdate, }) {
|
|
28
|
+
const [firstName, setFirstName] = useState(user.firstName ?? '');
|
|
29
|
+
const [lastName, setLastName] = useState(user.lastName ?? '');
|
|
30
|
+
const [saving, setSaving] = useState(false);
|
|
31
|
+
const [saved, setSaved] = useState(false);
|
|
32
|
+
const [error, setError] = useState(null);
|
|
33
|
+
async function save(e) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
setSaving(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
setSaved(false);
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${baseUrl}/v1/sessions/current`, {
|
|
40
|
+
method: 'PATCH',
|
|
41
|
+
credentials: 'include',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
firstName: firstName || null,
|
|
45
|
+
lastName: lastName || null,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
throw new Error('Could not save profile.');
|
|
50
|
+
setSaved(true);
|
|
51
|
+
if (onUpdate)
|
|
52
|
+
onUpdate({ firstName: firstName || null, lastName: lastName || null });
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
setError(err?.message ?? 'Could not save profile.');
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
setSaving(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return (_jsxs("form", { onSubmit: save, children: [_jsx("h3", { children: "Profile" }), _jsx("p", { className: "af-sub", children: "Your public identity across this application." }), _jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", htmlFor: "af-first", children: "First name" }), _jsx("input", { id: "af-first", className: "af-input", value: firstName, onChange: (e) => setFirstName(e.target.value), maxLength: 60 })] }), _jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", htmlFor: "af-last", children: "Last name" }), _jsx("input", { id: "af-last", className: "af-input", value: lastName, onChange: (e) => setLastName(e.target.value), maxLength: 60 })] }), _jsxs("div", { className: "af-row", children: [_jsx("span", { className: "af-row-key", children: "Email" }), _jsx("span", { className: "af-row-val", children: user.email ?? '—' })] }), _jsxs("div", { className: "af-row", children: [_jsx("span", { className: "af-row-key", children: "Username" }), _jsx("span", { className: "af-row-val", children: user.username ?? _jsx("span", { style: { color: '#9ca3af' }, children: "Not set" }) })] }), _jsx("div", { className: "af-primary-row", children: _jsx("button", { type: "submit", className: "af-btn af-btn-primary", disabled: saving, style: { width: 'auto' }, children: saving ? 'Saving…' : 'Save changes' }) }), error && _jsx("div", { className: "af-error", children: error }), saved && _jsx("div", { className: "af-notice", children: "Saved." })] }));
|
|
62
|
+
}
|
|
63
|
+
function SecurityTab({ baseUrl }) {
|
|
64
|
+
const [currentPassword, setCurrent] = useState('');
|
|
65
|
+
const [newPassword, setNewPassword] = useState('');
|
|
66
|
+
const [confirm, setConfirm] = useState('');
|
|
67
|
+
const [saving, setSaving] = useState(false);
|
|
68
|
+
const [error, setError] = useState(null);
|
|
69
|
+
const [ok, setOk] = useState(false);
|
|
70
|
+
async function submit(e) {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
setError(null);
|
|
73
|
+
setOk(false);
|
|
74
|
+
if (newPassword.length < 8)
|
|
75
|
+
return setError('New password must be at least 8 characters.');
|
|
76
|
+
if (newPassword !== confirm)
|
|
77
|
+
return setError('Passwords do not match.');
|
|
78
|
+
setSaving(true);
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`${baseUrl}/v1/auth/me/password`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
credentials: 'include',
|
|
83
|
+
headers: { 'content-type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ currentPassword, newPassword }),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok)
|
|
87
|
+
throw new Error('Could not update password.');
|
|
88
|
+
setOk(true);
|
|
89
|
+
setCurrent('');
|
|
90
|
+
setNewPassword('');
|
|
91
|
+
setConfirm('');
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
setError(err?.message ?? 'Could not update password.');
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
setSaving(false);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submit, children: [_jsx("h3", { children: "Security" }), _jsx("p", { className: "af-sub", children: "Update your password. Active sessions are not signed out automatically." }), _jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", children: "Current password" }), _jsx("input", { className: "af-input", type: "password", autoComplete: "current-password", required: true, value: currentPassword, onChange: (e) => setCurrent(e.target.value) })] }), _jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", children: "New password" }), _jsx("input", { className: "af-input", type: "password", autoComplete: "new-password", required: true, value: newPassword, onChange: (e) => setNewPassword(e.target.value) })] }), _jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", children: "Confirm new password" }), _jsx("input", { className: "af-input", type: "password", autoComplete: "new-password", required: true, value: confirm, onChange: (e) => setConfirm(e.target.value) })] }), _jsx("div", { className: "af-primary-row", children: _jsx("button", { type: "submit", className: "af-btn af-btn-primary", disabled: saving, style: { width: 'auto' }, children: saving ? 'Updating…' : 'Update password' }) }), error && _jsx("div", { className: "af-error", children: error }), ok && _jsx("div", { className: "af-notice", children: "Password updated." })] }), _jsx(MfaEnrollSection, { baseUrl: baseUrl })] }));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Two-factor (TOTP authenticator) enrollment. Generates a secret, has the user
|
|
104
|
+
* confirm a code from their authenticator app, then reveals one-time backup
|
|
105
|
+
* codes. Mirrors the instance API: /me/totp/generate -> /me/totp/verify ->
|
|
106
|
+
* /me/backup-codes/generate.
|
|
107
|
+
*/
|
|
108
|
+
function MfaEnrollSection({ baseUrl }) {
|
|
109
|
+
const [step, setStep] = useState('idle');
|
|
110
|
+
const [secret, setSecret] = useState('');
|
|
111
|
+
const [otpauth, setOtpauth] = useState('');
|
|
112
|
+
const [code, setCode] = useState('');
|
|
113
|
+
const [backupCodes, setBackupCodes] = useState([]);
|
|
114
|
+
const [busy, setBusy] = useState(false);
|
|
115
|
+
const [error, setError] = useState(null);
|
|
116
|
+
async function start() {
|
|
117
|
+
setBusy(true);
|
|
118
|
+
setError(null);
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(`${baseUrl}/v1/auth/me/totp/generate`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
credentials: 'include',
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
throw new Error('Could not start enrollment.');
|
|
126
|
+
const body = (await res.json());
|
|
127
|
+
setOtpauth(body.otpauth ?? '');
|
|
128
|
+
setSecret(body.secret ?? '');
|
|
129
|
+
setStep('verify');
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
setError(err?.message ?? 'Could not start enrollment.');
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
setBusy(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function confirm(e) {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
setBusy(true);
|
|
141
|
+
setError(null);
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetch(`${baseUrl}/v1/auth/me/totp/verify`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
credentials: 'include',
|
|
146
|
+
headers: { 'content-type': 'application/json' },
|
|
147
|
+
body: JSON.stringify({ code: code.trim() }),
|
|
148
|
+
});
|
|
149
|
+
const body = (await res.json().catch(() => ({})));
|
|
150
|
+
if (!res.ok || !body.ok)
|
|
151
|
+
throw new Error('That code is not valid. Try again.');
|
|
152
|
+
// Issue recovery codes now that the factor is confirmed.
|
|
153
|
+
const bc = await fetch(`${baseUrl}/v1/auth/me/backup-codes/generate`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
credentials: 'include',
|
|
156
|
+
});
|
|
157
|
+
const bcBody = (await bc.json().catch(() => ({})));
|
|
158
|
+
setBackupCodes(bcBody.codes ?? []);
|
|
159
|
+
setStep('done');
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
setError(err?.message ?? 'Verification failed.');
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
setBusy(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return (_jsxs("div", { style: { marginTop: 28, borderTop: '1px solid var(--af-border, #e5e7eb)', paddingTop: 20 }, children: [_jsx("h3", { children: "Two-step verification" }), _jsx("p", { className: "af-sub", children: "Add an authenticator app (TOTP) for a second factor at sign-in." }), step === 'idle' && (_jsx("div", { className: "af-primary-row", children: _jsx("button", { type: "button", className: "af-btn af-btn-primary", disabled: busy, style: { width: 'auto' }, onClick: start, children: busy ? 'Please wait…' : 'Enable authenticator app' }) })), step === 'verify' && (_jsxs("form", { onSubmit: confirm, children: [_jsx("p", { className: "af-sub", children: "Add this secret key to your authenticator app, then enter the 6-digit code it shows." }), _jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", children: "Secret key" }), _jsx("input", { className: "af-input", readOnly: true, value: secret, onFocus: (e) => e.currentTarget.select() })] }), _jsxs("div", { className: "af-field", children: [_jsx("label", { className: "af-label", children: "Authenticator code" }), _jsx("input", { className: "af-input", inputMode: "numeric", autoComplete: "one-time-code", required: true, value: code, onChange: (e) => setCode(e.target.value), placeholder: "123456" })] }), _jsx("div", { className: "af-primary-row", children: _jsx("button", { type: "submit", className: "af-btn af-btn-primary", disabled: busy, style: { width: 'auto' }, children: busy ? 'Verifying…' : 'Confirm & enable' }) })] })), step === 'done' && (_jsxs("div", { children: [_jsx("div", { className: "af-notice", children: "Two-step verification is on." }), backupCodes.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "af-sub", style: { marginTop: 14 }, children: "Save these one-time backup codes somewhere safe \u2014 each works once if you lose your authenticator." }), _jsx("pre", { className: "af-input", style: { whiteSpace: 'pre-wrap', fontFamily: 'monospace' }, children: backupCodes.join('\n') })] }))] })), error && _jsx("div", { className: "af-error", children: error })] }));
|
|
169
|
+
}
|
|
170
|
+
function SessionsTab({ baseUrl }) {
|
|
171
|
+
const [loading, setLoading] = useState(true);
|
|
172
|
+
const [error, setError] = useState(null);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
let cancelled = false;
|
|
175
|
+
(async () => {
|
|
176
|
+
try {
|
|
177
|
+
// Per-user session listing is on the roadmap; for now we offer a
|
|
178
|
+
// bulk sign-out action that ends every session for the user.
|
|
179
|
+
setLoading(false);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
if (!cancelled)
|
|
183
|
+
setError('Could not load sessions.');
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
return () => { cancelled = true; };
|
|
187
|
+
}, []);
|
|
188
|
+
async function endAll() {
|
|
189
|
+
try {
|
|
190
|
+
await fetch(`${baseUrl}/v1/auth/sign-out/all`, { method: 'POST', credentials: 'include' });
|
|
191
|
+
if (typeof window !== 'undefined')
|
|
192
|
+
window.location.href = '/';
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
setError(err?.message ?? 'Failed to sign out.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return (_jsxs("div", { children: [_jsx("h3", { children: "Active sessions" }), _jsx("p", { className: "af-sub", children: "Manage your signed-in devices." }), loading && _jsx("div", { className: "af-subtitle", children: "Loading\u2026" }), error && _jsx("div", { className: "af-error", children: error }), _jsxs("div", { className: "af-row", children: [_jsx("span", { className: "af-row-key", children: "This device" }), _jsx("span", { className: "af-pill af-pill-success", children: "Current" })] }), _jsx("div", { className: "af-primary-row", children: _jsx("button", { type: "button", className: "af-btn af-btn-danger", onClick: endAll, style: { width: 'auto' }, children: "Sign out of all devices" }) })] }));
|
|
199
|
+
}
|