Files
Termix/src/ui/sidebar/AdminUserDialogs.tsx
T
Luke Gustafson d1a8dcf3c6 release-2.4.1 (#921)
* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910)

Bumps the dev-minor-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` |
| [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` |
| [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` |
| [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` |



Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `cytoscape` from 3.33.4 to 3.34.0
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0)

Updates `electron` from 42.2.0 to 42.4.1
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1)

Updates `electron-builder` from 26.8.1 to 26.15.3
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder)

Updates `lucide-react` from 1.16.0 to 1.20.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react)

Updates `radix-ui` from 1.4.3 to 1.6.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-hook-form` from 7.76.1 to 7.79.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0)

Updates `sharp` from 0.34.5 to 0.35.1
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1)

Updates `typescript-eslint` from 8.60.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: cytoscape
  dependency-version: 3.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: electron
  dependency-version: 42.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: electron-builder
  dependency-version: 26.15.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: lucide-react
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: radix-ui
  dependency-version: 1.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.79.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: sharp
  dependency-version: 0.35.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump node in /docker in the docker-major-updates group (#914)

Bumps the docker-major-updates group in /docker with 1 update: node.


Updates `node` from 24-slim to 26-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump the github-actions group with 2 updates (#915)

Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916)

Bumps the prod-minor-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` |
| [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` |
| [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` |
| [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` |



Updates `axios` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

Updates `better-sqlite3` from 12.10.0 to 12.11.1
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1)

Updates `body-parser` from 2.2.2 to 2.3.0
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

Updates `multer` from 2.1.1 to 2.2.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0)

Updates `undici` from 8.4.0 to 8.5.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: better-sqlite3
  dependency-version: 12.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: multer
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add unlink button for dual auth

* fix: general bug fixes

* fix: general qol/small feature additions

* fix: 100mb max upload size

* fix: terminal syntax not handling carriage returns or shell prompt lines properly

* feat: continued general improvements

* fix: terminal outputting success right after folder path

* chore: update release notes

* feat: continued fixes and improvements

* chore: lint, format, and bump version to 2.4.1

* chore: sync Crowdin translations for 2.4.1

* fix: rebase dev branch onto main before Crowdin download

* fix: use merge instead of rebase to sync main before Crowdin

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-21 15:55:41 -05:00

599 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
assignRoleToUser,
linkOIDCToPasswordAccount,
removeRoleFromUser,
unlinkOIDCFromPasswordAccount,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
message?: string;
};
function apiErrorMessage(error: unknown, fallback: string) {
const err = error as ApiErrorLike;
return err.response?.data?.error || err.message || fallback;
}
type CreateUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
newUsername: string;
setNewUsername: Dispatch<SetStateAction<string>>;
newPassword: string;
setNewPassword: Dispatch<SetStateAction<string>>;
showNewPassword: boolean;
setShowNewPassword: Dispatch<SetStateAction<boolean>>;
handleCreateUser: () => void;
createUserLoading: boolean;
};
export function AdminCreateUserDialog({
open,
onOpenChange,
newUsername,
setNewUsername,
newPassword,
setNewPassword,
showNewPassword,
setShowNewPassword,
handleCreateUser,
createUserLoading,
}: CreateUserDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.createUserTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder="Enter password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
type EditUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
editUserTarget: AdminUser | null;
editUserLoading: boolean;
editUserRoles: UserRole[];
editUserRolesLoading: boolean;
roles: Role[];
setEditUserRoles: Dispatch<SetStateAction<UserRole[]>>;
handleToggleAdmin: (user: AdminUser) => void;
handleRevokeUserSessions: (userId: string) => void;
handleDeleteEditUser: () => void;
};
export function AdminEditUserDialog({
open,
onOpenChange,
editUserTarget,
editUserLoading,
editUserRoles,
editUserRolesLoading,
roles,
setEditUserRoles,
handleToggleAdmin,
handleRevokeUserSessions,
handleDeleteEditUser,
}: EditUserDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.editUserTitle", { username: editUserTarget?.username })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.editUserDesc")}
</DialogDescription>
</DialogHeader>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isOidc && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isOidc
? t("admin.authTypeOidc")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">
{t("admin.userRoles")}
</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter(
(r) => r.roleId !== ur.roleId,
),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(
editUserTarget.id,
r.id,
);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
type UnlinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
unlinkAccountTarget: { id: string; username: string } | null;
onSuccess: (userId: string) => void;
};
export function AdminUnlinkAccountDialog({
open,
onOpenChange,
unlinkAccountTarget,
onSuccess,
}: UnlinkAccountDialogProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!unlinkAccountTarget) return;
setSubmitting(true);
try {
await unlinkOIDCFromPasswordAccount(unlinkAccountTarget.id);
toast.success(t("admin.unlinkAccountSuccess"));
onSuccess(unlinkAccountTarget.id);
onOpenChange(false);
} catch (error: unknown) {
toast.error(apiErrorMessage(error, t("admin.unlinkAccountFailed")));
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.unlinkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
type LinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
linkAccountTarget: { id: string; username: string; isOidc: boolean } | null;
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
users: AdminUser[];
};
export function AdminLinkAccountDialog({
open,
onOpenChange,
linkAccountTarget,
setUsers,
users,
}: LinkAccountDialogProps) {
const { t } = useTranslation();
const [otherUsername, setOtherUsername] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) setOtherUsername("");
}, [open, linkAccountTarget]);
const isOidcInitiator = linkAccountTarget?.isOidc ?? true;
const handleSubmit = async () => {
const trimmed = otherUsername.trim();
if (!linkAccountTarget || !trimmed) return;
setSubmitting(true);
try {
if (isOidcInitiator) {
await linkOIDCToPasswordAccount(linkAccountTarget.id, trimmed);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
} else {
const oidcUser = users.find(
(u) => u.username === trimmed && u.isOidc && !u.passwordHash,
);
if (!oidcUser) {
toast.error(t("admin.linkAccountOidcNotFound"));
return;
}
await linkOIDCToPasswordAccount(
oidcUser.id,
linkAccountTarget.username,
);
setUsers((prev) => prev.filter((u) => u.id !== oidcUser.id));
}
toast.success(t("admin.linkAccountSuccess", { username: trimmed }));
setOtherUsername("");
onOpenChange(false);
} catch (error: unknown) {
toast.error(apiErrorMessage(error, t("admin.linkAccountFailed")));
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.linkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{isOidcInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isOidcInitiator
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountOidcUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isOidcInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountOidcPlaceholder")
}
autoFocus
disabled={submitting}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}