Compare commits
22 Commits
feature/ve
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06134d6e67 | ||
|
|
7fe9c581b0 | ||
|
|
7fc8a4d407 | ||
|
|
e21a9d6046 | ||
|
|
0481cefaf4 | ||
|
|
040ae60be5 | ||
|
|
d8f7452ab7 | ||
|
|
c4fe16048c | ||
|
|
cba420fbeb | ||
|
|
67ec409383 | ||
|
|
3384510f3c | ||
|
|
18e6f32f90 | ||
|
|
4df6fc9e03 | ||
|
|
2aa5100530 | ||
|
|
c360d9ad5f | ||
|
|
e7952dd9de | ||
|
|
687598952f | ||
|
|
c22580e124 | ||
|
|
a5c20830a7 | ||
|
|
9231a1fc60 | ||
| f325416833 | |||
| 0b4d0e3b2f |
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **cameleer-saas** (3336 symbols, 7094 relationships, 281 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **cameleer-saas** (3458 symbols, 7429 relationships, 292 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ api_patch "/api/sign-in-exp" '{
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"mfa": {
|
"mfa": {
|
||||||
"factors": ["Totp", "BackupCode"],
|
"factors": ["Totp", "WebAuthn", "BackupCode"],
|
||||||
"policy": "UserControlled"
|
"policy": "UserControlled"
|
||||||
}
|
}
|
||||||
}' >/dev/null 2>&1
|
}' >/dev/null 2>&1
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class AccountController {
|
|||||||
@PostMapping("/mfa/totp/verify")
|
@PostMapping("/mfa/totp/verify")
|
||||||
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
||||||
@RequestBody TotpVerifyRequest request) {
|
@RequestBody TotpVerifyRequest request) {
|
||||||
boolean ok = accountService.verifyTotpCode(request.secret(), request.code());
|
boolean ok = accountService.verifyAndEnableTotp(jwt.getSubject(), request.secret(), request.code());
|
||||||
return Map.of("verified", ok);
|
return Map.of("verified", ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ public class AccountService {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||||
}
|
}
|
||||||
|
Object nameVal = user.get("name");
|
||||||
|
Object emailVal = user.get("primaryEmail");
|
||||||
return new ProfileData(
|
return new ProfileData(
|
||||||
userId,
|
userId,
|
||||||
String.valueOf(user.getOrDefault("name", "")),
|
nameVal != null ? String.valueOf(nameVal) : "",
|
||||||
String.valueOf(user.getOrDefault("primaryEmail", ""))
|
emailVal != null ? String.valueOf(emailVal) : ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +110,32 @@ public class AccountService {
|
|||||||
new SecureRandom().nextBytes(secretBytes);
|
new SecureRandom().nextBytes(secretBytes);
|
||||||
String secret = base32Encode(secretBytes);
|
String secret = base32Encode(secretBytes);
|
||||||
|
|
||||||
var result = logtoClient.createTotpVerification(userId, secret);
|
// Build otpauth URI locally — do NOT register with Logto yet.
|
||||||
String qrCode = result.containsKey("secretQrCode")
|
// The secret is only registered after the user verifies the 6-digit code.
|
||||||
? String.valueOf(result.get("secretQrCode"))
|
var user = logtoClient.getUser(userId);
|
||||||
: String.valueOf(result.getOrDefault("qrCode", ""));
|
String email = user != null ? String.valueOf(user.getOrDefault("primaryEmail", "")) : "";
|
||||||
return new MfaSetupData(secret, qrCode);
|
String account = email.isBlank() ? userId : email;
|
||||||
|
|
||||||
|
// Include org name in issuer so authenticator apps show "Cameleer - OrgName"
|
||||||
|
String issuer = "Cameleer";
|
||||||
|
var orgs = logtoClient.getUserOrganizations(userId);
|
||||||
|
if (!orgs.isEmpty()) {
|
||||||
|
issuer = "Cameleer - " + orgs.getFirst().get("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodedIssuer = java.net.URLEncoder.encode(issuer, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
String encodedAccount = java.net.URLEncoder.encode(account, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
String otpauthUri = String.format(
|
||||||
|
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
|
||||||
|
encodedIssuer, encodedAccount, secret, encodedIssuer);
|
||||||
|
|
||||||
|
return new MfaSetupData(secret, otpauthUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean verifyAndEnableTotp(String userId, String secret, String code) {
|
||||||
|
if (!verifyTotpCode(secret, code)) return false;
|
||||||
|
logtoClient.createTotpVerification(userId, secret);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean verifyTotpCode(String secret, String code) {
|
public boolean verifyTotpCode(String secret, String code) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package net.siegeln.cameleer.saas.config;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures Logto sign-in experience always offers TOTP + WebAuthn + BackupCode
|
||||||
|
* on startup. Availability is always-on; enforcement is handled separately by
|
||||||
|
* MfaEnforcementFilter based on the vendor auth policy.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LogtoStartupConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
|
||||||
|
|
||||||
|
private final LogtoManagementClient logtoClient;
|
||||||
|
|
||||||
|
public LogtoStartupConfig(LogtoManagementClient logtoClient) {
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void onStartup() {
|
||||||
|
try {
|
||||||
|
List<String> factors = List.of("Totp", "WebAuthn", "BackupCode");
|
||||||
|
logtoClient.updateSignInExperience(Map.of(
|
||||||
|
"mfa", Map.of("factors", factors, "policy", "UserControlled")));
|
||||||
|
log.info("Logto MFA factors set to {} (UserControlled)", factors);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,19 +209,67 @@ public class LogtoManagementClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a user in Logto and add to organization with role. */
|
/** Delete a user from Logto entirely. */
|
||||||
|
public void deleteUser(String userId) {
|
||||||
|
if (!isAvailable() || userId == null) return;
|
||||||
|
try {
|
||||||
|
restClient.delete()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
log.info("Deleted user {} from Logto", userId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to delete user {}: {}", userId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a user by email. Returns user map or null if not found. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> findUserByEmail(String email) {
|
||||||
|
if (!isAvailable() || email == null) return null;
|
||||||
|
try {
|
||||||
|
var resp = restClient.get()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users?search="
|
||||||
|
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
|
||||||
|
+ "&search.primaryEmail="
|
||||||
|
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
|
||||||
|
+ "&page_size=5")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(List.class);
|
||||||
|
if (resp == null) return null;
|
||||||
|
return ((List<Map<String, Object>>) resp).stream()
|
||||||
|
.filter(u -> email.equalsIgnoreCase(String.valueOf(u.get("primaryEmail"))))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to find user by email: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a user in Logto (or find existing by email) and add to organization with role. */
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public String createAndInviteUser(String email, String orgId, String roleId) {
|
public String createAndInviteUser(String email, String orgId, String roleId) {
|
||||||
if (!isAvailable()) return null;
|
if (!isAvailable()) return null;
|
||||||
try {
|
try {
|
||||||
var userResp = (Map<String, Object>) restClient.post()
|
String userId;
|
||||||
.uri(config.getLogtoEndpoint() + "/api/users")
|
// Check if user already exists in Logto
|
||||||
.header("Authorization", "Bearer " + getAccessToken())
|
var existing = findUserByEmail(email);
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
if (existing != null) {
|
||||||
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
|
userId = String.valueOf(existing.get("id"));
|
||||||
.retrieve()
|
log.info("User '{}' already exists in Logto ({}), adding to org", email, userId);
|
||||||
.body(Map.class);
|
} else {
|
||||||
String userId = String.valueOf(userResp.get("id"));
|
var userResp = (Map<String, Object>) restClient.post()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
userId = String.valueOf(userResp.get("id"));
|
||||||
|
}
|
||||||
if (orgId != null) {
|
if (orgId != null) {
|
||||||
addUserToOrganization(orgId, userId);
|
addUserToOrganization(orgId, userId);
|
||||||
if (roleId != null) {
|
if (roleId != null) {
|
||||||
|
|||||||
@@ -181,13 +181,14 @@ public class TenantPortalService {
|
|||||||
return logtoClient.listOrganizationMembers(orgId);
|
return logtoClient.listOrganizationMembers(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String inviteTeamMember(String email, String roleId) {
|
public String inviteTeamMember(String email, String roleName) {
|
||||||
TenantEntity tenant = resolveTenant();
|
TenantEntity tenant = resolveTenant();
|
||||||
String orgId = tenant.getLogtoOrgId();
|
String orgId = tenant.getLogtoOrgId();
|
||||||
if (orgId == null || orgId.isBlank()) {
|
if (orgId == null || orgId.isBlank()) {
|
||||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||||
}
|
}
|
||||||
return logtoClient.createAndInviteUser(email, orgId, roleId);
|
String resolvedRoleId = resolveOrgRoleId(roleName);
|
||||||
|
return logtoClient.createAndInviteUser(email, orgId, resolvedRoleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeTeamMember(String userId) {
|
public void removeTeamMember(String userId) {
|
||||||
@@ -197,15 +198,33 @@ public class TenantPortalService {
|
|||||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||||
}
|
}
|
||||||
logtoClient.removeUserFromOrganization(orgId, userId);
|
logtoClient.removeUserFromOrganization(orgId, userId);
|
||||||
|
|
||||||
|
// If the user has no remaining org memberships, delete from Logto entirely
|
||||||
|
var remainingOrgs = logtoClient.getUserOrganizations(userId);
|
||||||
|
if (remainingOrgs.isEmpty()) {
|
||||||
|
log.info("User {} has no remaining org memberships — deleting from Logto", userId);
|
||||||
|
logtoClient.deleteUser(userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void changeTeamMemberRole(String userId, String roleId) {
|
public void changeTeamMemberRole(String userId, String roleName) {
|
||||||
TenantEntity tenant = resolveTenant();
|
TenantEntity tenant = resolveTenant();
|
||||||
String orgId = tenant.getLogtoOrgId();
|
String orgId = tenant.getLogtoOrgId();
|
||||||
if (orgId == null || orgId.isBlank()) {
|
if (orgId == null || orgId.isBlank()) {
|
||||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||||
}
|
}
|
||||||
logtoClient.assignOrganizationRole(orgId, userId, roleId);
|
String resolvedRoleId = resolveOrgRoleId(roleName);
|
||||||
|
logtoClient.assignOrganizationRole(orgId, userId, resolvedRoleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a role name (e.g. "viewer") to a Logto organization role ID. */
|
||||||
|
private String resolveOrgRoleId(String roleName) {
|
||||||
|
if (roleName == null || roleName.isBlank()) return null;
|
||||||
|
String resolved = logtoClient.findOrgRoleIdByName(roleName);
|
||||||
|
if (resolved == null) {
|
||||||
|
throw new IllegalArgumentException("Unknown organization role: " + roleName);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void resetServerAdminPassword(String newPassword) {
|
public void resetServerAdminPassword(String newPassword) {
|
||||||
|
|||||||
@@ -100,13 +100,12 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-muted);
|
color: var(--text-link, #C6820E);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
text-align: right;
|
text-align: center;
|
||||||
align-self: flex-end;
|
align-self: center;
|
||||||
margin-top: -8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgotLink:hover {
|
.forgotLink:hover {
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
|
||||||
import {
|
import {
|
||||||
signIn, startRegistration, completeRegistration,
|
signIn, startRegistration, completeRegistration,
|
||||||
startForgotPassword, forgotPasswordVerifyAndReset,
|
startForgotPassword, forgotPasswordVerifyAndReset,
|
||||||
verifyTotp, verifyBackupCode, submitMfa,
|
verifyTotp, verifyBackupCode, submitMfa,
|
||||||
startWebAuthnAuth, verifyWebAuthnAuth,
|
startWebAuthnAuth, verifyWebAuthnAuth,
|
||||||
MfaRequiredError,
|
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
|
||||||
|
generateBackupCodes, createTotpSecret, verifyTotpSetup,
|
||||||
|
skipMfaEnrollment, submitInteraction,
|
||||||
|
MfaRequiredError, MfaEnrollmentError,
|
||||||
} from './experience-api';
|
} from './experience-api';
|
||||||
import styles from './SignInPage.module.css';
|
import styles from './SignInPage.module.css';
|
||||||
|
|
||||||
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker';
|
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp' | 'mfaEnrollBackupCodes';
|
||||||
|
|
||||||
const SIGN_IN_SUBTITLES = [
|
const SIGN_IN_SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
@@ -87,6 +90,8 @@ export function SignInPage() {
|
|||||||
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||||
const [webauthnError, setWebauthnError] = useState('');
|
const [webauthnError, setWebauthnError] = useState('');
|
||||||
const [webauthnLoading, setWebauthnLoading] = useState(false);
|
const [webauthnLoading, setWebauthnLoading] = useState(false);
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||||
|
const [backupCodesSaved, setBackupCodesSaved] = useState(false);
|
||||||
|
|
||||||
// Fetch sign-in experience to check if registration is enabled
|
// Fetch sign-in experience to check if registration is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,16 +132,19 @@ export function SignInPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof MfaRequiredError) {
|
if (err instanceof MfaRequiredError) {
|
||||||
const pref = localStorage.getItem('mfa_method_preference');
|
const pref = localStorage.getItem('mfa_method_preference');
|
||||||
if (pref === 'webauthn') {
|
if (pref === 'totp') {
|
||||||
setMode('mfaWebauthn');
|
|
||||||
} else if (pref === 'totp') {
|
|
||||||
setMode('mfaVerify');
|
setMode('mfaVerify');
|
||||||
} else {
|
} else {
|
||||||
setMode('mfaMethodPicker');
|
setMode('mfaWebauthn');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (err instanceof MfaEnrollmentError) {
|
||||||
|
setMode('mfaEnroll');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -179,6 +187,11 @@ export function SignInPage() {
|
|||||||
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof MfaEnrollmentError) {
|
||||||
|
setMode('mfaEnroll');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : 'Verification failed');
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -258,13 +271,17 @@ export function SignInPage() {
|
|||||||
setWebauthnError('');
|
setWebauthnError('');
|
||||||
setWebauthnLoading(true);
|
setWebauthnLoading(true);
|
||||||
try {
|
try {
|
||||||
const options = await startWebAuthnAuth();
|
const { verificationId, authenticationOptions } = await startWebAuthnAuth();
|
||||||
const credential = await startAuthentication({ optionsJSON: options as any });
|
const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
|
||||||
const verificationId = await verifyWebAuthnAuth(credential as unknown as Record<string, unknown>);
|
await verifyWebAuthnAuth(verificationId, credential as unknown as Record<string, unknown>);
|
||||||
localStorage.setItem('mfa_method_preference', 'webauthn');
|
localStorage.setItem('mfa_method_preference', 'webauthn');
|
||||||
const redirectTo = await submitMfa(verificationId);
|
const redirectTo = await submitMfa(verificationId);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'NotAllowedError') {
|
||||||
|
setWebauthnLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
|
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
|
||||||
setWebauthnLoading(false);
|
setWebauthnLoading(false);
|
||||||
}
|
}
|
||||||
@@ -293,6 +310,90 @@ export function SignInPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- MFA enrollment ---
|
||||||
|
const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null);
|
||||||
|
const [totpCode, setTotpCode] = useState('');
|
||||||
|
|
||||||
|
async function handleEnrollPasskey() {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { verificationId, registrationOptions } = await startWebAuthnRegistration();
|
||||||
|
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
|
||||||
|
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
|
||||||
|
await bindMfaProfile('WebAuthn', verifiedId);
|
||||||
|
const bc = await generateBackupCodes();
|
||||||
|
await bindMfaProfile('BackupCode', bc.verificationId);
|
||||||
|
setBackupCodes(bc.codes);
|
||||||
|
setBackupCodesSaved(false);
|
||||||
|
setMode('mfaEnrollBackupCodes');
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'NotAllowedError') {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : 'Passkey registration failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartTotpEnroll() {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await createTotpSecret();
|
||||||
|
setTotpSetup(data);
|
||||||
|
setTotpCode('');
|
||||||
|
setMode('mfaEnrollTotp');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start TOTP setup');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyTotpEnroll(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const verifiedId = await verifyTotpSetup(totpCode);
|
||||||
|
await bindMfaProfile('Totp', verifiedId);
|
||||||
|
const bc = await generateBackupCodes();
|
||||||
|
await bindMfaProfile('BackupCode', bc.verificationId);
|
||||||
|
setBackupCodes(bc.codes);
|
||||||
|
setBackupCodesSaved(false);
|
||||||
|
setMode('mfaEnrollBackupCodes');
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackupCodesDone() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const redirectTo = await submitInteraction();
|
||||||
|
window.location.replace(redirectTo);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to complete sign-in');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSkipEnrollment() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const redirectTo = await skipMfaEnrollment();
|
||||||
|
window.location.replace(redirectTo);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to continue');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const passwordToggle = (
|
const passwordToggle = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -351,16 +452,6 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{emailConnectorConfigured && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.forgotLink}
|
|
||||||
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -371,6 +462,16 @@ export function SignInPage() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{emailConnectorConfigured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.forgotLink}
|
||||||
|
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{registrationEnabled && (
|
{registrationEnabled && (
|
||||||
<p className={styles.switchText}>
|
<p className={styles.switchText}>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
@@ -713,6 +814,116 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* --- MFA enrollment: choose method --- */}
|
||||||
|
{mode === 'mfaEnroll' && (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
||||||
|
Add an extra layer of security to your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
|
||||||
|
Use passkey
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={handleStartTotpEnroll} disabled={loading}>
|
||||||
|
Use authenticator app
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
|
||||||
|
Set up later
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- MFA enrollment: TOTP setup --- */}
|
||||||
|
{mode === 'mfaEnrollTotp' && totpSetup && (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
||||||
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Set up authenticator</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
||||||
|
Scan this QR code with your authenticator app, then enter the 6-digit code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||||
|
<img src={totpSetup.secretQrCode} alt="TOTP QR Code" width={180} height={180} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', padding: '6px 10px',
|
||||||
|
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
|
||||||
|
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.7rem',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{totpSetup.secret}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleVerifyTotpEnroll} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<FormField label="Verification code" htmlFor="enroll-totp-code">
|
||||||
|
<Input
|
||||||
|
id="enroll-totp-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="Enter 6-digit code"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button type="submit" variant="primary" loading={loading} disabled={totpCode.length !== 6}>
|
||||||
|
Verify & Enable
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => { setTotpSetup(null); setMode('mfaEnroll'); }} disabled={loading}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- MFA enrollment: backup codes --- */}
|
||||||
|
{mode === 'mfaEnrollBackupCodes' && backupCodes && (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
||||||
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Save your backup codes</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
||||||
|
Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '6px 20px', padding: 12,
|
||||||
|
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
|
||||||
|
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.85rem',
|
||||||
|
}}>
|
||||||
|
{backupCodes.map((c) => <span key={c}>{c}</span>)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
|
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(backupCodes.join('\n'))}>
|
||||||
|
Copy all
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => {
|
||||||
|
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = 'cameleer-backup-codes.txt'; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={backupCodesSaved} onChange={(e) => setBackupCodesSaved(e.target.checked)} />
|
||||||
|
I've saved my backup codes
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" disabled={!backupCodesSaved} onClick={handleBackupCodesDone} loading={loading}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export class MfaRequiredError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MfaEnrollmentError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('MFA enrollment available');
|
||||||
|
this.name = 'MfaEnrollmentError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function trySubmit(): Promise<{ ok: true; redirectTo: string } | { ok: false; status: number; code: string; message: string }> {
|
async function trySubmit(): Promise<{ ok: true; redirectTo: string } | { ok: false; status: number; code: string; message: string }> {
|
||||||
const res = await request('POST', '/submit');
|
const res = await request('POST', '/submit');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -92,18 +99,14 @@ export async function signIn(identifier: string, password: string): Promise<stri
|
|||||||
const result = await trySubmit();
|
const result = await trySubmit();
|
||||||
if (result.ok) return result.redirectTo;
|
if (result.ok) return result.redirectTo;
|
||||||
|
|
||||||
// MFA already enrolled — user must verify (show TOTP input)
|
// MFA already enrolled — user must verify (show TOTP/passkey input)
|
||||||
if (result.code === 'user.missing_mfa' || result.code === 'session.mfa.require_mfa_verification') {
|
if (result.code === 'session.mfa.require_mfa_verification') {
|
||||||
throw new MfaRequiredError();
|
throw new MfaRequiredError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// MFA not enrolled, UserControlled policy — skip the binding prompt.
|
// MFA not enrolled — offer enrollment (passkey / TOTP)
|
||||||
// Also fallback: any 422 with an MFA-related code we don't recognize — try skip before failing.
|
if (result.code === 'user.missing_mfa' || (result.status === 422 && result.code.includes('mfa'))) {
|
||||||
if (result.status === 422 && result.code.includes('mfa')) {
|
throw new MfaEnrollmentError();
|
||||||
await skipMfaBinding();
|
|
||||||
const retry = await trySubmit();
|
|
||||||
if (retry.ok) return retry.redirectTo;
|
|
||||||
throw new Error(retry.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(result.message);
|
throw new Error(result.message);
|
||||||
@@ -184,12 +187,9 @@ export async function completeRegistration(
|
|||||||
const result = await trySubmit();
|
const result = await trySubmit();
|
||||||
if (result.ok) return result.redirectTo;
|
if (result.ok) return result.redirectTo;
|
||||||
|
|
||||||
// MFA not enrolled, UserControlled policy — skip the binding prompt
|
// MFA not enrolled — offer enrollment (passkey / TOTP)
|
||||||
if (result.status === 422 && result.code.includes('mfa')) {
|
if (result.status === 422 && result.code.includes('mfa')) {
|
||||||
await skipMfaBinding();
|
throw new MfaEnrollmentError();
|
||||||
const retry = await trySubmit();
|
|
||||||
if (retry.ok) return retry.redirectTo;
|
|
||||||
throw new Error(retry.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(result.message);
|
throw new Error(result.message);
|
||||||
@@ -269,25 +269,25 @@ export async function verifyBackupCode(code: string): Promise<string> {
|
|||||||
return data.verificationId;
|
return data.verificationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitMfa(verificationId: string): Promise<string> {
|
export async function submitMfa(_verificationId: string): Promise<string> {
|
||||||
await identifyUser(verificationId);
|
// User is already identified from the initial sign-in step.
|
||||||
|
// MFA verification is stored in the experience session — just submit.
|
||||||
return submitInteraction();
|
return submitInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WebAuthn MFA Verification ---
|
// --- WebAuthn MFA Verification ---
|
||||||
|
|
||||||
export async function startWebAuthnAuth(): Promise<Record<string, unknown>> {
|
export async function startWebAuthnAuth(): Promise<{ verificationId: string; authenticationOptions: Record<string, unknown> }> {
|
||||||
const res = await request('POST', '/verification/web-authn/authentication');
|
const res = await request('POST', '/verification/web-authn/authentication');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.message || `Failed to start passkey authentication (${res.status})`);
|
throw new Error(err.message || `Failed to start passkey authentication (${res.status})`);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
return res.json();
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyWebAuthnAuth(payload: Record<string, unknown>): Promise<string> {
|
export async function verifyWebAuthnAuth(verificationId: string, payload: Record<string, unknown>): Promise<string> {
|
||||||
const res = await request('POST', '/verification/web-authn/authentication/verify', payload);
|
const res = await request('POST', '/verification/web-authn/authentication/verify', { verificationId, payload: { ...payload, type: 'WebAuthn' } });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
if (res.status === 422) {
|
if (res.status === 422) {
|
||||||
@@ -298,3 +298,68 @@ export async function verifyWebAuthnAuth(payload: Record<string, unknown>): Prom
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.verificationId;
|
return data.verificationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MFA Enrollment (during sign-in) ---
|
||||||
|
|
||||||
|
export async function startWebAuthnRegistration(): Promise<{ verificationId: string; registrationOptions: Record<string, unknown> }> {
|
||||||
|
const res = await request('POST', '/verification/web-authn/registration');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to start passkey registration (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyWebAuthnRegistration(verificationId: string, payload: Record<string, unknown>): Promise<string> {
|
||||||
|
const body = { ...payload, type: 'WebAuthn' };
|
||||||
|
const res = await request('POST', '/verification/web-authn/registration/verify', { verificationId, payload: body });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Passkey registration verification failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindMfaProfile(type: string, verificationId: string): Promise<void> {
|
||||||
|
const res = await request('POST', '/profile/mfa', { type, verificationId });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to bind MFA (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateBackupCodes(): Promise<{ verificationId: string; codes: string[] }> {
|
||||||
|
const res = await request('POST', '/verification/backup-code/generate');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to generate backup codes (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTotpSecret(): Promise<{ secret: string; secretQrCode: string; verificationId: string }> {
|
||||||
|
const res = await request('POST', '/verification/totp/secret');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to create TOTP secret (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotpSetup(code: string): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/totp/verify', { code });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `TOTP verification failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipMfaEnrollment(): Promise<string> {
|
||||||
|
await skipMfaBinding();
|
||||||
|
const result = await trySubmit();
|
||||||
|
if (result.ok) return result.redirectTo;
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ export function Layout() {
|
|||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const isTenantAdmin = scopes.has('tenant:manage');
|
const isTenantAdmin = scopes.has('tenant:manage');
|
||||||
const onVendorRoute = location.pathname.startsWith('/vendor');
|
const onTenantRoute = location.pathname.startsWith('/tenant');
|
||||||
// Vendor on vendor routes: show only TENANTS. On tenant routes: show tenant portal too (for debugging).
|
const onVendorRoute = location.pathname.startsWith('/vendor') || (isVendor && !onTenantRoute);
|
||||||
const showTenantPortal = isTenantAdmin && (!isVendor || !onVendorRoute);
|
// Vendor on vendor routes (or neutral pages like account settings): show only vendor sidebar.
|
||||||
|
// On tenant routes: show tenant portal too (for debugging).
|
||||||
|
const showTenantPortal = isTenantAdmin && (!isVendor || onTenantRoute);
|
||||||
|
|
||||||
// Build breadcrumbs from path
|
// Build breadcrumbs from path
|
||||||
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);
|
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
FormField,
|
FormField,
|
||||||
Input,
|
Input,
|
||||||
|
Modal,
|
||||||
Spinner,
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
} from '../../api/account-hooks';
|
} from '../../api/account-hooks';
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
export function MfaSection() {
|
export function MfaSection({ bare }: { bare?: boolean }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus();
|
const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus();
|
||||||
const setup = useAccountMfaSetup();
|
const setup = useAccountMfaSetup();
|
||||||
@@ -34,6 +35,8 @@ export function MfaSection() {
|
|||||||
const [codesSaved, setCodesSaved] = useState(false);
|
const [codesSaved, setCodesSaved] = useState(false);
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
|
||||||
|
const modalOpen = !!setupData || !!codes;
|
||||||
|
|
||||||
async function handleStartSetup() {
|
async function handleStartSetup() {
|
||||||
try {
|
try {
|
||||||
const data = await setup.mutateAsync();
|
const data = await setup.mutateAsync();
|
||||||
@@ -87,6 +90,19 @@ export function MfaSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleModalClose() {
|
||||||
|
// During backup codes step, only allow close after confirming saved
|
||||||
|
if (codes) return;
|
||||||
|
// During setup, safe to cancel — TOTP is not registered until verified
|
||||||
|
setSetupData(null);
|
||||||
|
setVerifyCode('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackupCodesDone() {
|
||||||
|
setCodes(null);
|
||||||
|
setCodesSaved(false);
|
||||||
|
}
|
||||||
|
|
||||||
function handleCopyAll() {
|
function handleCopyAll() {
|
||||||
if (!codes) return;
|
if (!codes) return;
|
||||||
navigator.clipboard.writeText(codes.join('\n'));
|
navigator.clipboard.writeText(codes.join('\n'));
|
||||||
@@ -105,159 +121,154 @@ export function MfaSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (statusLoading) {
|
if (statusLoading) {
|
||||||
return (
|
const spinner = <div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}><Spinner /></div>;
|
||||||
<Card title="Multi-Factor Authentication">
|
return bare ? spinner : <Card title="Multi-Factor Authentication">{spinner}</Card>;
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup codes display
|
const content = (
|
||||||
if (codes) {
|
<>
|
||||||
return (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
<Card title="Multi-Factor Authentication">
|
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
|
||||||
<Alert variant="warning" title="Save your backup codes">
|
{mfaStatus?.enrolled ? (
|
||||||
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
|
<Badge label="Enrolled" color="success" />
|
||||||
</Alert>
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr',
|
|
||||||
gap: '8px 24px',
|
|
||||||
marginTop: 16,
|
|
||||||
padding: '16px',
|
|
||||||
background: 'var(--bg-inset)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontFamily: 'var(--font-mono, monospace)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}>
|
|
||||||
{codes.map((code) => (
|
|
||||||
<span key={code}>{code}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
||||||
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
|
|
||||||
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
|
|
||||||
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
|
|
||||||
I've saved my backup codes
|
|
||||||
</label>
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup flow — QR code + verification
|
|
||||||
if (setupData) {
|
|
||||||
return (
|
|
||||||
<Card title="Multi-Factor Authentication">
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
|
|
||||||
{setupData.secretQrCode.startsWith('data:') ? (
|
|
||||||
<img src={setupData.secretQrCode} alt="TOTP QR Code" width={200} height={200} />
|
|
||||||
) : (
|
) : (
|
||||||
<QRCodeSVG value={setupData.secretQrCode} size={200} />
|
<Badge label="Not enrolled" color="auto" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: 'var(--bg-inset)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontFamily: 'var(--font-mono, monospace)',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}>
|
|
||||||
{setupData.secret}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<FormField label="Verification code" htmlFor="mfa-code">
|
|
||||||
<Input
|
|
||||||
id="mfa-code"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={6}
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
value={verifyCode}
|
|
||||||
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
placeholder="Enter 6-digit code"
|
|
||||||
required
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
|
|
||||||
Verify & Enable
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main view — enrolled or not
|
|
||||||
return (
|
|
||||||
<Card title="Multi-Factor Authentication">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
|
||||||
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
|
|
||||||
{mfaStatus?.enrolled ? (
|
{mfaStatus?.enrolled ? (
|
||||||
<Badge label="Enrolled" color="success" />
|
<>
|
||||||
) : (
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
<Badge label="Not enrolled" color="auto" />
|
Your account is protected with a TOTP authenticator app.
|
||||||
)}
|
</p>
|
||||||
</div>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{mfaStatus?.enrolled ? (
|
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
|
||||||
<>
|
Regenerate backup codes
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
</Button>
|
||||||
Your account is protected with a TOTP authenticator app.
|
{confirmRemove ? (
|
||||||
</p>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<Alert variant="error" title="This will disable MFA on your account." />
|
||||||
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
|
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
|
||||||
Regenerate backup codes
|
Confirm removal
|
||||||
</Button>
|
</Button>
|
||||||
{confirmRemove ? (
|
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
Cancel
|
||||||
<Alert variant="error" title="This will disable MFA on your account." />
|
</Button>
|
||||||
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
|
</div>
|
||||||
Confirm removal
|
) : (
|
||||||
|
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
|
||||||
|
Remove MFA
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
|
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
|
||||||
|
Set up authenticator app
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{bare ? content : <Card title="Multi-Factor Authentication">{content}</Card>}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
title={codes ? 'Save your backup codes' : 'Set up authenticator'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{codes ? (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning" title="Save your backup codes">
|
||||||
|
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
|
||||||
|
</Alert>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '8px 24px',
|
||||||
|
marginTop: 16,
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}>
|
||||||
|
{codes.map((code) => (
|
||||||
|
<span key={code}>{code}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
|
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
|
||||||
|
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
|
||||||
|
I've saved my backup codes
|
||||||
|
</label>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button variant="primary" disabled={!codesSaved} onClick={handleBackupCodesDone}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : setupData ? (
|
||||||
|
<>
|
||||||
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
|
||||||
|
<QRCodeSVG value={setupData.secretQrCode} size={200} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'var(--bg-inset)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
{setupData.secret}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<FormField label="Verification code" htmlFor="mfa-code">
|
||||||
|
<Input
|
||||||
|
id="mfa-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
value={verifyCode}
|
||||||
|
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="Enter 6-digit code"
|
||||||
|
required
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
|
||||||
|
Verify & Enable
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={handleModalClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</form>
|
||||||
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
|
</>
|
||||||
Remove MFA
|
) : null}
|
||||||
</Button>
|
</Modal>
|
||||||
)}
|
</>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
|
|
||||||
Set up authenticator app
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function PasskeyNudgeBanner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PasskeySection() {
|
export function PasskeySection({ bare }: { bare?: boolean }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: passkeys, isLoading } = useAccountPasskeyList();
|
const { data: passkeys, isLoading } = useAccountPasskeyList();
|
||||||
const renamePasskey = useAccountRenamePasskey();
|
const renamePasskey = useAccountRenamePasskey();
|
||||||
@@ -85,17 +85,17 @@ export function PasskeySection() {
|
|||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
const credentials = passkeys ?? [];
|
const credentials = passkeys ?? [];
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Card title="Passkeys">
|
<>
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
Use your fingerprint, face, or security key to sign in faster.
|
Use your fingerprint, face, or security key to sign in faster.
|
||||||
</p>
|
</p>
|
||||||
{credentials.length === 0 ? (
|
{credentials.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||||
No passkeys registered. Passkeys can be registered during sign-in when prompted.
|
No passkeys registered. You can register a passkey during sign-in.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{credentials.map((pk) => (
|
{credentials.map((pk) => (
|
||||||
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
|
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -107,9 +107,9 @@ export function PasskeySection() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
|
<div style={{ fontWeight: 500 }}>{pk.name || parseAgent(pk.agent)}</div>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
{parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
|
Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -131,6 +131,8 @@ export function PasskeySection() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return bare ? content : <Card title="Passkeys">{content}</Card>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function OnboardingPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
||||||
const [checkingSlug, setCheckingSlug] = useState(false);
|
const [checkingSlug, setCheckingSlug] = useState(false);
|
||||||
const [showPasskeyOffer, setShowPasskeyOffer] = useState(false);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
const slug = toSlug(name);
|
const slug = toSlug(name);
|
||||||
@@ -51,17 +50,6 @@ export function OnboardingPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
||||||
// Check if passkeys are enabled in vendor policy
|
|
||||||
try {
|
|
||||||
const config = await fetch('/platform/api/config').then(r => r.json());
|
|
||||||
if (config.vendorAuthPolicy?.passkeyEnabled) {
|
|
||||||
setShowPasskeyOffer(true);
|
|
||||||
setLoading(false);
|
|
||||||
return; // Don't redirect yet
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore — proceed without passkey offer
|
|
||||||
}
|
|
||||||
// Tenant created — force a fresh OIDC sign-in so the Logto SDK gets
|
// Tenant created — force a fresh OIDC sign-in so the Logto SDK gets
|
||||||
// new tokens that include the org membership just created. The existing
|
// new tokens that include the org membership just created. The existing
|
||||||
// Logto session cookie means the user won't see a login form — Logto
|
// Logto session cookie means the user won't see a login form — Logto
|
||||||
@@ -78,34 +66,6 @@ export function OnboardingPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSkipPasskey() {
|
|
||||||
await signIn(`${window.location.origin}/platform/callback`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showPasskeyOffer) {
|
|
||||||
return (
|
|
||||||
<div className={styles.page}>
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<Card className={styles.card}>
|
|
||||||
<div className={styles.inner}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<h2 style={{ margin: '16px 0 8px' }}>Secure your account</h2>
|
|
||||||
<p style={{ color: 'var(--text-muted)', marginBottom: 24 }}>
|
|
||||||
Add a passkey to sign in faster with your fingerprint, face, or security key.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<Button variant="secondary" onClick={handleSkipPasskey}>
|
|
||||||
Set up later
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
|
|||||||
@@ -13,14 +13,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
useTenantSettings,
|
useTenantSettings,
|
||||||
useResetServerAdminPassword,
|
useResetServerAdminPassword,
|
||||||
useUpdateTenantSettings,
|
|
||||||
useTenantAuthSettings, useUpdateTenantAuthSettings,
|
useTenantAuthSettings, useUpdateTenantAuthSettings,
|
||||||
} from '../../api/tenant-hooks';
|
} from '../../api/tenant-hooks';
|
||||||
import { MfaSection } from '../../components/account/MfaSection';
|
import { PasskeySection } from '../../components/account/PasskeySection';
|
||||||
import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection';
|
|
||||||
import { PasswordChangeSection } from '../../components/account/PasswordChangeSection';
|
|
||||||
import { useScopes } from '../../auth/useScopes';
|
import { useScopes } from '../../auth/useScopes';
|
||||||
import { tierColor } from '../../utils/tier';
|
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
|
function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
|
||||||
@@ -33,78 +29,6 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function MfaEnforcementToggle() {
|
|
||||||
const scopes = useScopes();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { data: settings } = useTenantSettings();
|
|
||||||
const updateSettings = useUpdateTenantSettings();
|
|
||||||
const [confirmEnable, setConfirmEnable] = useState(false);
|
|
||||||
|
|
||||||
if (!scopes.has('tenant:manage')) return null;
|
|
||||||
|
|
||||||
const mfaRequired = settings?.mfaRequired ?? false;
|
|
||||||
|
|
||||||
async function handleToggle() {
|
|
||||||
if (!mfaRequired) {
|
|
||||||
setConfirmEnable(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateSettings.mutateAsync({ mfaRequired: false });
|
|
||||||
toast({ title: 'MFA requirement disabled for all members', variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirmEnable() {
|
|
||||||
try {
|
|
||||||
await updateSettings.mutateAsync({ mfaRequired: true });
|
|
||||||
setConfirmEnable(false);
|
|
||||||
toast({ title: 'MFA is now required for all members', variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card title="MFA Enforcement">
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
When enabled, all team members will be required to set up multi-factor authentication before accessing this tenant.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<span style={{ fontSize: '0.875rem' }}>Require MFA for all members</span>
|
|
||||||
<Badge label={mfaRequired ? 'Required' : 'Optional'} color={mfaRequired ? 'success' : 'auto'} />
|
|
||||||
</div>
|
|
||||||
{confirmEnable ? (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Alert variant="warning" title="Confirm MFA requirement">
|
|
||||||
All team members who have not enrolled in MFA will need to set it up on their next login. Are you sure?
|
|
||||||
</Alert>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
||||||
<Button variant="primary" onClick={handleConfirmEnable} loading={updateSettings.isPending}>
|
|
||||||
Yes, require MFA
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setConfirmEnable(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Button
|
|
||||||
variant={mfaRequired ? 'danger' : 'primary'}
|
|
||||||
onClick={handleToggle}
|
|
||||||
loading={updateSettings.isPending}
|
|
||||||
>
|
|
||||||
{mfaRequired ? 'Disable MFA requirement' : 'Enable MFA requirement'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthPolicySection() {
|
function AuthPolicySection() {
|
||||||
const scopes = useScopes();
|
const scopes = useScopes();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -212,89 +136,86 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflowY: 'auto', flex: 1 }}>
|
||||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
|
||||||
<PasskeyNudgeBanner />
|
|
||||||
|
|
||||||
<Card title="Tenant Details">
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20 }}>
|
||||||
<div className={styles.dividerList}>
|
{/* Card 1: Tenant Details */}
|
||||||
<div className={styles.kvRow}>
|
<div style={{ maxWidth: 520, flex: '1 1 400px' }}>
|
||||||
<span className={styles.kvLabel}>Name</span>
|
<Card title="Tenant Details">
|
||||||
<span className={styles.kvValue}>{data.name}</span>
|
<div className={styles.dividerList}>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Name</span>
|
||||||
|
<span className={styles.kvValue}>{data.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Slug</span>
|
||||||
|
<span className={styles.kvValueMono}>{data.slug}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Status</span>
|
||||||
|
<Badge label={data.status} color={statusColor(data.status)} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Server Endpoint</span>
|
||||||
|
<span className={styles.kvValueMono}>{data.serverEndpoint ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Created</span>
|
||||||
|
<span className={styles.kvValue}>{new Date(data.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.kvRow}>
|
|
||||||
<span className={styles.kvLabel}>Slug</span>
|
<div style={{ borderTop: '1px solid var(--border)', marginTop: 16, paddingTop: 16 }}>
|
||||||
<span className={styles.kvValueMono}>{data.slug}</span>
|
<p className={styles.description} style={{ margin: '0 0 12px' }}>
|
||||||
</div>
|
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
|
||||||
<div className={styles.kvRow}>
|
</p>
|
||||||
<span className={styles.kvLabel}>Tier</span>
|
<form
|
||||||
<Badge label={data.tier} color={tierColor(data.tier)} />
|
onSubmit={async (e) => {
|
||||||
</div>
|
e.preventDefault();
|
||||||
<div className={styles.kvRow}>
|
if (serverAdminPw.length < 8) {
|
||||||
<span className={styles.kvLabel}>Status</span>
|
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
|
||||||
<Badge label={data.status} color={statusColor(data.status)} />
|
return;
|
||||||
</div>
|
}
|
||||||
<div className={styles.kvRow}>
|
try {
|
||||||
<span className={styles.kvLabel}>Server Endpoint</span>
|
await resetServerAdmin.mutateAsync(serverAdminPw);
|
||||||
<span className={styles.kvValueMono}>{data.serverEndpoint ?? '—'}</span>
|
toast({ title: 'Server admin password reset successfully', variant: 'success' });
|
||||||
</div>
|
setServerAdminPw('');
|
||||||
<div className={styles.kvRow}>
|
} catch (err) {
|
||||||
<span className={styles.kvLabel}>Created</span>
|
toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' });
|
||||||
<span className={styles.kvValue}>{new Date(data.createdAt).toLocaleDateString()}</span>
|
}
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<FormField label="Server admin password" htmlFor="server-admin-pw">
|
||||||
|
<Input
|
||||||
|
id="server-admin-pw"
|
||||||
|
type="password"
|
||||||
|
value={serverAdminPw}
|
||||||
|
onChange={(e) => setServerAdminPw(e.target.value)}
|
||||||
|
placeholder="Enter new admin password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={styles.description} style={{ marginTop: 16 }}>
|
{/* Card 2: Authentication Policy (org-wide settings) */}
|
||||||
To change your tier or other billing-related settings, please contact support.
|
<div style={{ maxWidth: 520, flex: '1 1 400px' }}>
|
||||||
</p>
|
<AuthPolicySection />
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PasswordChangeSection />
|
{/* Card 3: Passkeys (full width) */}
|
||||||
|
|
||||||
<Card title="Server Admin Password">
|
|
||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
|
||||||
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (serverAdminPw.length < 8) {
|
|
||||||
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await resetServerAdmin.mutateAsync(serverAdminPw);
|
|
||||||
toast({ title: 'Server admin password reset successfully', variant: 'success' });
|
|
||||||
setServerAdminPw('');
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}
|
|
||||||
>
|
|
||||||
<FormField label="New admin password" htmlFor="server-admin-pw">
|
|
||||||
<Input
|
|
||||||
id="server-admin-pw"
|
|
||||||
type="password"
|
|
||||||
value={serverAdminPw}
|
|
||||||
onChange={(e) => setServerAdminPw(e.target.value)}
|
|
||||||
placeholder="Enter new admin password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div>
|
|
||||||
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
|
|
||||||
Reset Admin Password
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<MfaSection />
|
|
||||||
<MfaEnforcementToggle />
|
|
||||||
<PasskeySection />
|
<PasskeySection />
|
||||||
<AuthPolicySection />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user