Files
upstream-affine/packages/frontend/admin/src/modules/accounts/components/user-form.tsx
upstream-mirror 702cc698a7
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
2026-09-16 04:01:20 +05:00

334 lines
8.7 KiB
TypeScript

import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import type { FeatureType } from '@affine/graphql';
import { ChevronRightIcon } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import type { UserInput, UserType } from '../schema';
import { validateEmails, validatePassword } from '../utils/csv-utils';
import { useCreateUser, useUpdateUser } from './use-user-management';
type UserFormProps = {
title: string;
defaultValue?: Partial<UserInput>;
onClose: () => void;
onConfirm: (user: UserInput) => void;
onValidate: (user: Partial<UserInput>) => boolean;
actions?: React.ReactNode;
showOption?: boolean;
onDirtyChange?: (dirty: boolean) => void;
};
function UserForm({
title,
defaultValue,
onClose,
onConfirm,
onValidate,
actions,
showOption,
onDirtyChange,
}: UserFormProps) {
const serverConfig = useServerConfig();
const defaultUser: Partial<UserInput> = useMemo(
() => ({
name: defaultValue?.name ?? '',
email: defaultValue?.email ?? '',
password: defaultValue?.password ?? '',
features: defaultValue?.features ?? [],
}),
[defaultValue]
);
const [changes, setChanges] = useState<Partial<UserInput>>(defaultUser);
const setField = useCallback(
<K extends keyof UserInput>(
field: K,
value: UserInput[K] | ((prev: UserInput[K] | undefined) => UserInput[K])
) => {
setChanges(changes => ({
...changes,
[field]:
typeof value === 'function' ? value(changes[field] as any) : value,
}));
},
[]
);
const canSave = useMemo(() => {
return onValidate(changes);
}, [onValidate, changes]);
useEffect(() => {
const normalize = (value: Partial<UserInput>) => ({
name: value.name ?? '',
email: value.email ?? '',
password: value.password ?? '',
features: [...(value.features ?? [])].sort(),
});
const current = normalize(changes);
const baseline = normalize(defaultUser);
const dirty =
(current.name !== baseline.name ||
current.email !== baseline.email ||
current.password !== baseline.password ||
current.features.join(',') !== baseline.features.join(',')) &&
!!onDirtyChange;
onDirtyChange?.(dirty);
}, [changes, defaultUser, onDirtyChange]);
const handleConfirm = useCallback(() => {
if (!canSave) {
return;
}
// @ts-expect-error checked
onConfirm(changes);
setChanges(defaultUser);
}, [canSave, changes, defaultUser, onConfirm]);
const handleFeaturesChange = useCallback(
(features: FeatureType[]) => {
setField('features', features);
},
[setField]
);
const handleClose = useCallback(() => {
setChanges(defaultUser);
onClose();
}, [defaultUser, onClose]);
useEffect(() => {
setChanges(defaultUser);
}, [defaultUser]);
return (
<div className="flex h-full flex-col bg-background">
<RightPanelHeader
title={title}
handleClose={handleClose}
handleConfirm={handleConfirm}
canSave={canSave}
/>
<div className="flex-grow space-y-3 overflow-y-auto p-4">
<div className="flex flex-col rounded-xl border border-border bg-card shadow-sm">
<InputItem
label="User name"
field="name"
value={changes.name}
onChange={setField}
placeholder="Enter user name"
/>
<Separator />
<InputItem
label="Email"
field="email"
value={changes.email}
onChange={setField}
placeholder="Enter email address"
/>
{showOption && (
<>
<Separator />
<InputItem
label="Password"
field="password"
value={changes.password}
onChange={setField}
optional
placeholder="Enter password"
/>
</>
)}
</div>
<FeatureToggleList
className="rounded-xl border border-border bg-card shadow-sm"
features={serverConfig.availableUserFeatures}
selected={changes.features ?? []}
onChange={handleFeaturesChange}
control="switch"
controlPosition="right"
showSeparators={true}
/>
{actions}
</div>
</div>
);
}
function InputItem({
label,
field,
optional,
value,
onChange,
placeholder,
}: {
label: string;
field: keyof UserInput;
optional?: boolean;
value?: string;
onChange: (field: keyof UserInput, value: string) => void;
placeholder?: string;
}) {
const onValueChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(field, e.target.value);
},
[field, onChange]
);
return (
<div className="flex flex-col gap-2 p-3">
<Label className="flex flex-wrap text-xs font-medium leading-5 text-muted-foreground uppercase tracking-wide">
{label}
{optional && (
<span className="ml-1 font-normal text-muted-foreground">
(optional)
</span>
)}
</Label>
<Input
type="text"
className="py-2 px-3 text-sm font-normal h-9"
value={value}
onChange={onValueChange}
placeholder={placeholder}
/>
</div>
);
}
const validateCreateUser = (user: Partial<UserInput>) => {
return !!user.name && !!user.email && !!user.features;
};
const validateUpdateUser = (user: Partial<UserInput>) => {
return !!user.name || !!user.email;
};
export function CreateUserForm({
onComplete,
onDirtyChange,
}: {
onComplete: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { create, creating } = useCreateUser();
const serverConfig = useServerConfig();
const passwordLimits = serverConfig.credentialsRequirement.password;
useEffect(() => {
if (creating) {
return () => {
onComplete();
};
}
return;
}, [creating, onComplete]);
const handleCreateUser = useCallback(
(user: UserInput) => {
const emailValidation = validateEmails([user]);
const passwordValidation = validatePassword(
user.password,
passwordLimits
);
if (!passwordValidation.valid || !emailValidation[0].valid) {
toast.error(passwordValidation.error || emailValidation[0].error);
return;
}
create(user);
},
[create, passwordLimits]
);
return (
<UserForm
title="Create User"
onClose={onComplete}
onConfirm={handleCreateUser}
onValidate={validateCreateUser}
showOption={true}
onDirtyChange={onDirtyChange}
/>
);
}
export function UpdateUserForm({
user,
onResetPassword,
onDeleteAccount,
onComplete,
onDirtyChange,
}: {
user: UserType;
onResetPassword: () => void;
onDeleteAccount: () => void;
onComplete: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { update, updating } = useUpdateUser();
const onUpdateUser = useCallback(
(updates: UserInput) => {
update({
...updates,
userId: user.id,
});
},
[user, update]
);
useEffect(() => {
if (updating) {
return () => {
onComplete();
};
}
return;
}, [updating, onComplete]);
return (
<UserForm
title="Update User"
defaultValue={user}
onClose={onComplete}
onConfirm={onUpdateUser}
onValidate={validateUpdateUser}
onDirtyChange={onDirtyChange}
actions={
<div className="space-y-2">
<Button
className="h-10 w-full justify-between rounded-xl border-border/60 px-4 text-sm font-medium hover:bg-muted/50"
variant="outline"
onClick={onResetPassword}
>
<span>Reset Password</span>
<ChevronRightIcon size={16} className="text-muted-foreground" />
</Button>
<Button
className="h-10 w-full justify-between rounded-xl border-destructive/30 px-4 text-sm font-medium text-destructive hover:bg-destructive/5 hover:text-destructive"
variant="outline"
onClick={onDeleteAccount}
>
<span>Delete Account</span>
<ChevronRightIcon size={16} />
</Button>
</div>
}
/>
);
}