passkeys
This commit is contained in:
+123
-48
@@ -36,6 +36,12 @@ import { createPreviewSurface } from './preview/PreviewWorkspace';
|
||||
import { createDesktopSurface } from './DesktopWorkspace';
|
||||
import { AppShellContext, useAppShell } from './appShellContext';
|
||||
import LoginView from './login/LoginView';
|
||||
import usePasskeys from './settings/usePasskeys';
|
||||
import {
|
||||
isWebAuthnAvailable,
|
||||
preparePublicKeyRequestOptions,
|
||||
serializeAuthenticationCredential,
|
||||
} from './utils/webauthn';
|
||||
|
||||
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
@@ -1733,12 +1739,29 @@ const AppLayout = () => {
|
||||
setWebdavTokenSecret(null);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
registeringPasskey,
|
||||
revokingPasskeyId,
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
} = usePasskeys({
|
||||
api,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
token,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsModalOpen) {
|
||||
return;
|
||||
}
|
||||
refreshWebdavTokens();
|
||||
}, [isSettingsModalOpen, refreshWebdavTokens]);
|
||||
refreshPasskeys();
|
||||
}, [isSettingsModalOpen, refreshPasskeys, refreshWebdavTokens]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsModalOpen) {
|
||||
@@ -5463,6 +5486,14 @@ const AppLayout = () => {
|
||||
onDelete={deleteWebdavToken}
|
||||
createdToken={webdavTokenSecret}
|
||||
onDismissCreatedToken={dismissCreatedWebdavToken}
|
||||
passkeys={passkeys}
|
||||
passkeysSupported={passkeysSupported}
|
||||
passkeysLoading={passkeysLoading}
|
||||
registeringPasskey={registeringPasskey}
|
||||
revokingPasskeyId={revokingPasskeyId}
|
||||
onRefreshPasskeys={refreshPasskeys}
|
||||
onRegisterPasskey={registerPasskey}
|
||||
onRevokePasskey={revokePasskey}
|
||||
/>
|
||||
</div>
|
||||
</AppShellContext.Provider>
|
||||
@@ -5666,6 +5697,8 @@ const LoginRoute = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||
const passkeySupported = isWebAuthnAvailable();
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
@@ -5683,52 +5716,6 @@ const LoginRoute = () => {
|
||||
[reportLoginError],
|
||||
);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
username: form.get('username')?.toString().trim(),
|
||||
password: form.get('password')?.toString() || '',
|
||||
};
|
||||
|
||||
if (!payload.username || !payload.password) {
|
||||
setStatusMessage('Username and password are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
const { data } = await api.post('/auth/login', payload);
|
||||
if (data?.access_token && Array.isArray(data?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: message });
|
||||
notifyLoginError(error, message);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyLoginError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenant) => {
|
||||
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
||||
@@ -5772,6 +5759,92 @@ const LoginRoute = () => {
|
||||
setStatusMessage(null);
|
||||
}, [appDispatch, setStatusMessage]);
|
||||
|
||||
const handlePasskeyLogin = useCallback(
|
||||
async (rawUsername) => {
|
||||
const username = (rawUsername || '').trim();
|
||||
if (!username) {
|
||||
setStatusMessage('Enter your username before using a passkey.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!passkeySupported) {
|
||||
setStatusMessage('Passkeys are not supported in this browser.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyLoading(true);
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
try {
|
||||
const { data: startData } = await api.post('/auth/passkeys/login/start', { username });
|
||||
const challengeId = startData?.challengeId || startData?.challenge_id;
|
||||
const publicKeyOptions =
|
||||
startData?.publicKey
|
||||
|| startData?.public_key
|
||||
|| startData?.publicKeyCredentialRequestOptions
|
||||
|| startData?.challenge?.publicKey
|
||||
|| startData?.challenge?.public_key;
|
||||
|
||||
if (!challengeId || !publicKeyOptions) {
|
||||
throw new Error('Invalid passkey challenge response.');
|
||||
}
|
||||
|
||||
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
|
||||
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
|
||||
const assertion = await navigator.credentials.get({ publicKey });
|
||||
|
||||
if (!assertion) {
|
||||
setStatusMessage('Passkey login cancelled.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeAuthenticationCredential(assertion);
|
||||
const finishPayload = {
|
||||
challengeId,
|
||||
credential: serialized,
|
||||
};
|
||||
|
||||
const { data: finishData } = await api.post('/auth/passkeys/login/finish', finishPayload);
|
||||
|
||||
if (finishData?.access_token && Array.isArray(finishData?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: finishData.access_token,
|
||||
tenants: finishData.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finishData?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: finishData.access_token,
|
||||
tenant: finishData.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setStatusMessage('Passkey login cancelled.', 'info');
|
||||
} else if (error?.response?.status === 404) {
|
||||
setStatusMessage('No passkeys found for this user. Sign in with your password to add one.', 'error');
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' });
|
||||
} else if (error?.response?.status === 400 && error?.response?.data?.error) {
|
||||
setStatusMessage(error.response.data.error, 'error');
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error });
|
||||
} else {
|
||||
const message = error?.response?.data?.error || error.message || 'Passkey login failed.';
|
||||
notifyLoginError(error, message);
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: message });
|
||||
}
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyLoginError, passkeySupported, setStatusMessage],
|
||||
);
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const target = location.state?.from;
|
||||
if (typeof target === 'string' && target.startsWith('/')) {
|
||||
@@ -5787,12 +5860,14 @@ const LoginRoute = () => {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<LoginView
|
||||
onSubmit={handleLogin}
|
||||
status={status}
|
||||
tenantSelection={tenantSelection}
|
||||
onSelectTenant={handleTenantSelect}
|
||||
onCancelSelection={handleCancelSelection}
|
||||
selectingTenantId={selectingTenantId}
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyLoading={passkeyLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user