mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
feat(server): support refresh token (#15218)
This commit is contained in:
@@ -3,6 +3,7 @@ import type { ComponentType } from 'react';
|
||||
|
||||
import CONFIG_DESCRIPTORS from '../../config.json';
|
||||
import type { ConfigInputProps } from './config-input-row';
|
||||
import { AuthSigningKeys } from './operations/auth-signing-keys';
|
||||
import { SendTestEmail } from './operations/send-test-email';
|
||||
export type ConfigType = 'String' | 'Number' | 'Boolean' | 'JSON' | 'Enum';
|
||||
|
||||
@@ -75,6 +76,7 @@ export const KNOWN_CONFIG_GROUPS = [
|
||||
desc: 'Maximum length requirement of password',
|
||||
},
|
||||
],
|
||||
operations: [AuthSigningKeys],
|
||||
} as ConfigGroup<'auth'>,
|
||||
{
|
||||
name: 'Notification',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutate: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/admin/use-query', () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
authSigningKeys: [
|
||||
{
|
||||
id: 'active-key',
|
||||
status: 'active',
|
||||
source: 'auto',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
retiredAt: null,
|
||||
verifyUntil: null,
|
||||
canDelete: false,
|
||||
},
|
||||
{
|
||||
id: 'expired-key',
|
||||
status: 'retiring',
|
||||
source: 'admin',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
retiredAt: '2025-01-02T00:00:00.000Z',
|
||||
verifyUntil: '2025-01-02T01:00:00.000Z',
|
||||
canDelete: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
mutate: mocks.mutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/admin/use-mutation', () => ({
|
||||
useMutation: ({ mutation }: { mutation: { id: string } }) => ({
|
||||
trigger:
|
||||
mutation.id === 'rotateAuthSigningKeyMutation'
|
||||
? mocks.rotate
|
||||
: mocks.remove,
|
||||
isMutating: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/shared/confirm-dialog', () => ({
|
||||
ConfirmDialog: ({
|
||||
open,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
}) => (open ? <button onClick={onConfirm}>confirm-action</button> : null),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/component', () => ({
|
||||
notify: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { AuthSigningKeys } from './auth-signing-keys';
|
||||
|
||||
describe('AuthSigningKeys', () => {
|
||||
beforeEach(() => {
|
||||
mocks.mutate.mockReset().mockResolvedValue(undefined);
|
||||
mocks.remove.mockReset().mockResolvedValue({});
|
||||
mocks.rotate.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test('rotates the active key and deletes only an expired key', async () => {
|
||||
render(<AuthSigningKeys />);
|
||||
|
||||
expect(screen.queryByText(/secret/i)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rotate key' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'confirm-action' }));
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.rotate).toHaveBeenCalledWith({
|
||||
expectedActiveKeyId: 'active-key',
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'confirm-action' }));
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.remove).toHaveBeenCalledWith({ id: 'expired-key' })
|
||||
);
|
||||
expect(mocks.mutate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Badge } from '@affine/admin/components/ui/badge';
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@affine/admin/components/ui/card';
|
||||
import { useMutation } from '@affine/admin/use-mutation';
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { notify } from '@affine/component';
|
||||
import type { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
authSigningKeysQuery,
|
||||
deleteAuthSigningKeyMutation,
|
||||
rotateAuthSigningKeyMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ConfirmDialog } from '../../../components/shared/confirm-dialog';
|
||||
|
||||
type PendingAction =
|
||||
| { type: 'rotate'; keyId: string }
|
||||
| { type: 'delete'; keyId: string };
|
||||
|
||||
export function AuthSigningKeys() {
|
||||
const { data, mutate } = useQuery({ query: authSigningKeysQuery });
|
||||
const { trigger: rotate, isMutating: rotating } = useMutation({
|
||||
mutation: rotateAuthSigningKeyMutation,
|
||||
});
|
||||
const { trigger: remove, isMutating: deleting } = useMutation({
|
||||
mutation: deleteAuthSigningKeyMutation,
|
||||
});
|
||||
const [pending, setPending] = useState<PendingAction>();
|
||||
const keys = useMemo(
|
||||
() =>
|
||||
[...data.authSigningKeys].sort((left, right) =>
|
||||
left.status === right.status ? 0 : left.status === 'active' ? -1 : 1
|
||||
),
|
||||
[data.authSigningKeys]
|
||||
);
|
||||
const active = keys.find(key => key.status === 'active');
|
||||
const mutating = rotating || deleting;
|
||||
|
||||
const confirm = async () => {
|
||||
if (!pending) return;
|
||||
try {
|
||||
if (pending.type === 'rotate') {
|
||||
await rotate({ expectedActiveKeyId: pending.keyId });
|
||||
notify.success({
|
||||
title: 'Signing key rotated',
|
||||
message: 'New access tokens now use the replacement key.',
|
||||
});
|
||||
} else {
|
||||
await remove({ id: pending.keyId });
|
||||
notify.success({
|
||||
title: 'Signing key deleted',
|
||||
message: 'The expired signing key was removed.',
|
||||
});
|
||||
}
|
||||
setPending(undefined);
|
||||
await mutate();
|
||||
} catch (error) {
|
||||
const friendly = error as UserFriendlyError;
|
||||
notify.error({
|
||||
title: 'Signing key update failed',
|
||||
message: friendly.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 shadow-none">
|
||||
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-sm">Access token signing keys</CardTitle>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
This server generated and stored its signing key automatically.
|
||||
Rotate it here when needed; key material is never shown in the admin
|
||||
panel.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!active || mutating}
|
||||
onClick={() => {
|
||||
if (active) setPending({ type: 'rotate', keyId: active.id });
|
||||
}}
|
||||
>
|
||||
Rotate key
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{keys.length === 0 ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
No active signing key is available. Restart the server to retry
|
||||
automatic initialization.
|
||||
</div>
|
||||
) : (
|
||||
keys.map(key => {
|
||||
return (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 px-3 py-3"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="truncate text-xs">{key.id}</code>
|
||||
<Badge
|
||||
variant={
|
||||
key.status === 'active' ? 'default' : 'secondary'
|
||||
}
|
||||
>
|
||||
{key.status === 'active' ? 'Active' : 'Retiring'}
|
||||
</Badge>
|
||||
{key.source === 'auto' ? (
|
||||
<Badge variant="outline">Auto-generated</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Created {formatDate(key.createdAt)}
|
||||
{key.verifyUntil
|
||||
? ` · Verifiable until ${formatDate(key.verifyUntil)}`
|
||||
: ''}
|
||||
{key.retiredAt
|
||||
? ` · Retired ${formatDate(key.retiredAt)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
{key.status === 'retiring' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!key.canDelete || mutating}
|
||||
title={
|
||||
key.canDelete
|
||||
? 'Delete expired key'
|
||||
: 'This key can be deleted after its verification window ends.'
|
||||
}
|
||||
onClick={() =>
|
||||
setPending({ type: 'delete', keyId: key.id })
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pending}
|
||||
onOpenChange={open => {
|
||||
if (!open && !mutating) setPending(undefined);
|
||||
}}
|
||||
title={
|
||||
pending?.type === 'delete'
|
||||
? 'Delete signing key?'
|
||||
: 'Rotate signing key?'
|
||||
}
|
||||
description={
|
||||
pending?.type === 'delete'
|
||||
? 'The expired key will be permanently removed.'
|
||||
: 'A new key will become active immediately. The current key remains available only long enough to verify access tokens already issued.'
|
||||
}
|
||||
confirmText={pending?.type === 'delete' ? 'Delete key' : 'Rotate key'}
|
||||
confirmButtonVariant={
|
||||
pending?.type === 'delete' ? 'destructive' : 'default'
|
||||
}
|
||||
onConfirm={() => {
|
||||
confirm().catch(console.error);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : 'Unknown';
|
||||
}
|
||||
Reference in New Issue
Block a user