0.2.38: optional two-factor authentication (authenticator-app TOTP)
Per-user opt-in 2FA: enroll from the Two-factor option next to Change password (QR + confirm code + 8 one-time recovery codes), login becomes two-step for enrolled users, disable requires the account password. Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS action. Second-factor guesses share the login rate limiter; the pending login window expires after 5 minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
053bfeab23
commit
0822eca887
@@ -3,7 +3,7 @@
|
||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||
// - /api/* is never cached
|
||||
// Bump CACHE on each release so old entries are purged.
|
||||
const CACHE = 'ten31-portal-0.2.37'
|
||||
const CACHE = 'ten31-portal-0.2.38'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
|
||||
+30
-1
@@ -44,9 +44,15 @@ export interface User {
|
||||
is_active: boolean;
|
||||
is_service_admin: boolean;
|
||||
primary_account_id: number | null;
|
||||
totp_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Password was accepted but the account needs its authenticator code to finish signing in. */
|
||||
export interface LoginPending2FA {
|
||||
requires_2fa: true;
|
||||
}
|
||||
|
||||
export interface LinkedAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -279,11 +285,17 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
|
||||
export const api = {
|
||||
login: (login: string, password: string) =>
|
||||
request<User>("/api/auth/login", {
|
||||
request<User | LoginPending2FA>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ login, password }),
|
||||
}),
|
||||
|
||||
verifyTotp: (code: string) =>
|
||||
request<User>("/api/auth/login/verify-totp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
|
||||
logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
me: () => request<User>("/api/auth/me"),
|
||||
@@ -294,6 +306,23 @@ export const api = {
|
||||
body: JSON.stringify({ current_password, new_password }),
|
||||
}),
|
||||
|
||||
totpSetup: () =>
|
||||
request<{ secret: string; otpauth_uri: string; qr_svg: string }>("/api/auth/totp/setup", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
totpConfirm: (code: string) =>
|
||||
request<{ recovery_codes: string[] }>("/api/auth/totp/confirm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
|
||||
totpDisable: (password: string) =>
|
||||
request<{ status: string }>("/api/auth/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
|
||||
// Entities
|
||||
listEntities: () => request<Entity[]>("/api/entities"),
|
||||
listEntityRollup: () =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isAdmin } from "../api";
|
||||
import { roleLabel } from "../format";
|
||||
import { APP_VERSION } from "../version";
|
||||
import ChangePasswordModal from "./ChangePasswordModal";
|
||||
import TwoFactorModal from "./TwoFactorModal";
|
||||
|
||||
const TOP_NAV = [
|
||||
{ label: "Entities", path: "/" },
|
||||
@@ -26,6 +27,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [changingPw, setChangingPw] = useState(false);
|
||||
const [managing2fa, setManaging2fa] = useState(false);
|
||||
const navItems = user && isAdmin(user.role)
|
||||
? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV]
|
||||
: [...TOP_NAV, ...BOTTOM_NAV];
|
||||
@@ -83,6 +85,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setManaging2fa(true); setNavOpen(false); }}
|
||||
className="block text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Two-factor auth
|
||||
</button>
|
||||
<div className="text-xs text-gray-400">v{APP_VERSION}</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -122,6 +130,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
|
||||
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
|
||||
{managing2fa && <TwoFactorModal onClose={() => setManaging2fa(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import PasswordInput from "./PasswordInput";
|
||||
|
||||
/** Self-service two-factor (authenticator app) enrollment for the signed-in user. */
|
||||
export default function TwoFactorModal({ onClose }: { onClose: () => void }) {
|
||||
const { user, retry } = useAuth();
|
||||
const [enabled, setEnabled] = useState(!!user?.totp_enabled);
|
||||
const [setup, setSetup] = useState<{ secret: string; qr_svg: string } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [disabling, setDisabling] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Refresh the cached user (totp_enabled) once the modal closes after a change.
|
||||
const close = () => {
|
||||
if (enabled !== !!user?.totp_enabled) retry();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const s = await api.totpSetup();
|
||||
setSetup({ secret: s.secret, qr_svg: s.qr_svg });
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Could not start setup");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.totpConfirm(code);
|
||||
setRecoveryCodes(res.recovery_codes);
|
||||
setEnabled(true);
|
||||
setSetup(null);
|
||||
setCode("");
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Could not confirm code");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.totpDisable(password);
|
||||
setEnabled(false);
|
||||
setDisabling(false);
|
||||
setPassword("");
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Could not disable two-factor");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={close}>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-lg w-full max-w-md p-6 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Two-factor authentication</h3>
|
||||
|
||||
{recoveryCodes ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-green-700">Two-factor authentication is now enabled.</p>
|
||||
<div>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
Save these recovery codes somewhere safe. Each works once if you lose your
|
||||
authenticator — <span className="font-medium">they will not be shown again</span>.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 bg-gray-50 border border-gray-200 rounded p-3 font-mono text-xs text-gray-800">
|
||||
{recoveryCodes.map((c) => (
|
||||
<span key={c}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={close} className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800">
|
||||
I've saved my recovery codes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : setup ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
Scan this QR code with an authenticator app (Google Authenticator, 1Password,
|
||||
Authy…), then enter the 6-digit code it shows.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(setup.qr_svg)}`}
|
||||
alt="Two-factor enrollment QR code"
|
||||
className="w-44 h-44 border border-gray-200 rounded"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center break-all">
|
||||
Can't scan? Enter this key manually: <span className="font-mono">{setup.secret}</span>
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Code from your app</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="6-digit code"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={close} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirm}
|
||||
disabled={busy || code.trim().length < 6}
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Checking…" : "Turn on"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : enabled ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500 mr-1.5 align-middle" />
|
||||
Two-factor authentication is <span className="font-medium">on</span>. Signing in
|
||||
requires a code from your authenticator app.
|
||||
</p>
|
||||
{disabling ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Confirm your password to turn it off
|
||||
</label>
|
||||
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setDisabling(false); setPassword(""); setError(""); }}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={disable}
|
||||
disabled={busy || !password}
|
||||
className="px-3 py-1.5 bg-red-700 text-white text-sm rounded hover:bg-red-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Turning off…" : "Turn off two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={close} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDisabling(true)}
|
||||
className="px-3 py-1.5 text-sm text-red-700 border border-red-200 rounded hover:bg-red-50"
|
||||
>
|
||||
Turn off…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
Add a second step to sign-in: after your password, you'll enter a 6-digit code from
|
||||
an authenticator app on your phone. This keeps your account safe even if your
|
||||
password is guessed.
|
||||
</p>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={close} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Preparing…" : "Set up two-factor"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,10 @@ interface AuthState {
|
||||
loading: boolean;
|
||||
offline: boolean; // couldn't reach the server (vs. genuinely logged out)
|
||||
retry: () => void;
|
||||
login: (handle: string, password: string) => Promise<void>;
|
||||
/** Resolves "ok" when signed in, "2fa" when an authenticator code is still needed. */
|
||||
login: (handle: string, password: string) => Promise<"ok" | "2fa">;
|
||||
/** Second login step: authenticator code or a recovery code. */
|
||||
verifyTotp: (code: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -34,8 +37,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => { bootstrap(); }, [bootstrap]);
|
||||
|
||||
const login = async (handle: string, password: string) => {
|
||||
const u = await api.login(handle, password);
|
||||
const login = async (handle: string, password: string): Promise<"ok" | "2fa"> => {
|
||||
const res = await api.login(handle, password);
|
||||
if ("requires_2fa" in res) return "2fa";
|
||||
setUser(res);
|
||||
setOffline(false);
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const verifyTotp = async (code: string) => {
|
||||
const u = await api.verifyTotp(code);
|
||||
setUser(u);
|
||||
setOffline(false);
|
||||
};
|
||||
@@ -46,7 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, offline, retry: bootstrap, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, loading, offline, retry: bootstrap, login, verifyTotp, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useAuth } from "../context/AuthContext";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const { login, verifyTotp } = useAuth();
|
||||
const [handle, setHandle] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [needsCode, setNeedsCode] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -14,7 +16,8 @@ export default function Login() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(handle, password);
|
||||
const result = await login(handle, password);
|
||||
if (result === "2fa") setNeedsCode(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Login failed");
|
||||
} finally {
|
||||
@@ -22,6 +25,25 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await verifyTotp(code);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Verification failed");
|
||||
// A 401 "expired" means the pending window lapsed — send them back to step one.
|
||||
if (/expired/i.test(err.message || "")) {
|
||||
setNeedsCode(false);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-96">
|
||||
@@ -29,31 +51,68 @@ export default function Login() {
|
||||
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
|
||||
<h1 className="text-xl font-semibold text-gray-900">Ten31 Portal</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Username or email</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Password</label>
|
||||
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
{needsCode ? (
|
||||
<form onSubmit={handleCodeSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Two-factor code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="6-digit code"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
Enter the code from your authenticator app, or one of your recovery codes.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Verifying..." : "Verify"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setNeedsCode(false); setCode(""); setError(""); }}
|
||||
className="w-full text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Username or email</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Password</label>
|
||||
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<p className="mt-5 text-xs text-gray-400 text-center">
|
||||
Trouble signing in?{" "}
|
||||
<a href="mailto:Portal@ten31.xyz" className="text-accent-600 hover:text-accent-700">
|
||||
|
||||
@@ -3,10 +3,12 @@ import { useAuth } from "../context/AuthContext";
|
||||
import { roleLabel } from "../format";
|
||||
import { APP_VERSION } from "../version";
|
||||
import ChangePasswordModal from "../components/ChangePasswordModal";
|
||||
import TwoFactorModal from "../components/TwoFactorModal";
|
||||
|
||||
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [changingPw, setChangingPw] = useState(false);
|
||||
const [managing2fa, setManaging2fa] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@@ -19,7 +21,12 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
|
||||
{user ? roleLabel(user.role) : ""}
|
||||
</span>
|
||||
<button onClick={() => setChangingPw(true)} className="text-sm text-gray-500 hover:text-gray-800">
|
||||
Change password
|
||||
<span className="sm:hidden">Password</span>
|
||||
<span className="hidden sm:inline">Change password</span>
|
||||
</button>
|
||||
<button onClick={() => setManaging2fa(true)} className="text-sm text-gray-500 hover:text-gray-800">
|
||||
<span className="sm:hidden">2FA</span>
|
||||
<span className="hidden sm:inline">Two-factor</span>
|
||||
</button>
|
||||
<button onClick={logout} className="text-sm text-gray-500 hover:text-gray-800">
|
||||
Sign out
|
||||
@@ -29,6 +36,7 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
|
||||
</header>
|
||||
<main className="max-w-4xl mx-auto p-4 sm:p-6">{children}</main>
|
||||
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
|
||||
{managing2fa && <TwoFactorModal onClose={() => setManaging2fa(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Bumped each release so the running build is visible in the UI.
|
||||
// If the number shown in the app doesn't match the installed s9pk version,
|
||||
// the new frontend isn't actually being served.
|
||||
export const APP_VERSION = "0.2.37";
|
||||
export const APP_VERSION = "0.2.38";
|
||||
|
||||
Reference in New Issue
Block a user