feat: improve terminal stability and split out the host manager
This commit is contained in:
319
src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx
Normal file
319
src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { isElectron } from "@/ui/main-axios.ts";
|
||||
|
||||
interface DatabaseSecurityTabProps {
|
||||
currentUser: {
|
||||
is_oidc: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function DatabaseSecurityTab({
|
||||
currentUser,
|
||||
}: DatabaseSecurityTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
const [exportPassword, setExportPassword] = React.useState("");
|
||||
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
);
|
||||
|
||||
const handleExportDatabase = async () => {
|
||||
if (!showPasswordInput) {
|
||||
setShowPasswordInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exportPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}/database/export`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ password: exportPassword }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
const filename =
|
||||
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
"termix-export.sqlite";
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||
setExportPassword("");
|
||||
setShowPasswordInput(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportDatabase = async () => {
|
||||
if (!importFile) {
|
||||
toast.error(t("admin.pleaseSelectImportFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresImportPassword && !importPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/import`
|
||||
: `${window.location.protocol}//${window.location.host}/database/import`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
if (requiresImportPassword) {
|
||||
formData.append("password", importPassword);
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const summary = result.summary;
|
||||
const imported =
|
||||
summary.sshHostsImported +
|
||||
summary.sshCredentialsImported +
|
||||
summary.fileManagerItemsImported +
|
||||
summary.dismissedAlertsImported +
|
||||
(summary.settingsImported || 0);
|
||||
const skipped = summary.skippedItems;
|
||||
|
||||
const details = [];
|
||||
if (summary.sshHostsImported > 0)
|
||||
details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||
if (summary.sshCredentialsImported > 0)
|
||||
details.push(`${summary.sshCredentialsImported} credentials`);
|
||||
if (summary.fileManagerItemsImported > 0)
|
||||
details.push(
|
||||
`${summary.fileManagerItemsImported} file manager items`,
|
||||
);
|
||||
if (summary.dismissedAlertsImported > 0)
|
||||
details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||
if (summary.settingsImported > 0)
|
||||
details.push(`${summary.settingsImported} settings`);
|
||||
|
||||
toast.success(
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||
);
|
||||
setImportFile(null);
|
||||
setImportPassword("");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-semibold">{t("admin.export")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
</p>
|
||||
{showPasswordInput && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="export-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="export-password"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleExportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading
|
||||
? t("admin.exporting")
|
||||
: showPasswordInput
|
||||
? t("admin.confirmExport")
|
||||
: t("admin.export")}
|
||||
</Button>
|
||||
{showPasswordInput && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordInput(false);
|
||||
setExportPassword("");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<h4 className="font-semibold">{t("admin.import")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.importDescription")}
|
||||
</p>
|
||||
<div className="relative inline-block w-full mb-2">
|
||||
<input
|
||||
id="import-file-upload"
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||
>
|
||||
{importFile
|
||||
? importFile.name
|
||||
: t("admin.pleaseSelectImportFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && requiresImportPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="import-password"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleImportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={
|
||||
importLoading ||
|
||||
!importFile ||
|
||||
(requiresImportPassword && !importPassword.trim())
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx
Normal file
125
src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
updateRegistrationAllowed,
|
||||
updatePasswordLoginAllowed,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface GeneralSettingsTabProps {
|
||||
allowRegistration: boolean;
|
||||
setAllowRegistration: (value: boolean) => void;
|
||||
allowPasswordLogin: boolean;
|
||||
setAllowPasswordLogin: (value: boolean) => void;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function GeneralSettingsTab({
|
||||
allowRegistration,
|
||||
setAllowRegistration,
|
||||
allowPasswordLogin,
|
||||
setAllowPasswordLogin,
|
||||
oidcConfig,
|
||||
}: GeneralSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||
|
||||
const handleToggleRegistration = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
try {
|
||||
await updateRegistrationAllowed(checked);
|
||||
setAllowRegistration(checked);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||
if (!checked) {
|
||||
const hasOIDCConfigured =
|
||||
oidcConfig.client_id &&
|
||||
oidcConfig.client_secret &&
|
||||
oidcConfig.issuer_url &&
|
||||
oidcConfig.authorization_url &&
|
||||
oidcConfig.token_url;
|
||||
|
||||
if (!hasOIDCConfigured) {
|
||||
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisablePasswordLogin"),
|
||||
async () => {
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
|
||||
if (allowRegistration) {
|
||||
await updateRegistrationAllowed(false);
|
||||
setAllowRegistration(false);
|
||||
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
|
||||
} else {
|
||||
toast.success(t("admin.passwordLoginDisabled"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowRegistration}
|
||||
onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowNewAccountRegistration")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordLogin}
|
||||
onCheckedChange={handleTogglePasswordLogin}
|
||||
disabled={passwordLoginLoading}
|
||||
/>
|
||||
{t("admin.allowPasswordLogin")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
src/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx
Normal file
319
src/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { updateOIDCConfig, disableOIDCConfig } from "@/ui/main-axios.ts";
|
||||
|
||||
interface OIDCSettingsTabProps {
|
||||
allowPasswordLogin: boolean;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
};
|
||||
setOidcConfig: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function OIDCSettingsTab({
|
||||
allowPasswordLogin,
|
||||
oidcConfig,
|
||||
setOidcConfig,
|
||||
}: OIDCSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
|
||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOidcLoading(true);
|
||||
setOidcError(null);
|
||||
|
||||
const required = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
];
|
||||
const missing = required.filter(
|
||||
(f) => !oidcConfig[f as keyof typeof oidcConfig],
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(
|
||||
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
|
||||
);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("admin.failedToUpdateOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||
setOidcConfig((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleResetConfig = async () => {
|
||||
if (!allowPasswordLogin) {
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisableOIDCWarning"),
|
||||
async () => {
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-3">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.externalAuthentication")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.configureExternalProvider")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open("https://docs.termix.site/oidc", "_blank")}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!allowPasswordLogin && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||
<AlertDescription>{t("admin.oidcRequiredWarning")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">{t("admin.clientId")}</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_id", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientId")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">{t("admin.clientSecret")}</Label>
|
||||
<PasswordInput
|
||||
id="client_secret"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_secret", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientSecret")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">
|
||||
{t("admin.authorizationUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("authorization_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.authUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("issuer_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.redirectUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("token_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.tokenUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">
|
||||
{t("admin.userIdentifierPath")}
|
||||
</Label>
|
||||
<Input
|
||||
id="identifier_path"
|
||||
value={oidcConfig.identifier_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("identifier_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.userIdField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">{t("admin.displayNamePath")}</Label>
|
||||
<Input
|
||||
id="name_path"
|
||||
value={oidcConfig.name_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("name_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.usernameField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">{t("admin.scopes")}</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange("scopes", e.target.value)}
|
||||
placeholder={t("placeholders.scopes")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">{t("admin.overrideUserInfoUrl")}</Label>
|
||||
<Input
|
||||
id="userinfo_url"
|
||||
value={oidcConfig.userinfo_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("userinfo_url", e.target.value)
|
||||
}
|
||||
placeholder="https://your-provider.com/application/o/userinfo/"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1" disabled={oidcLoading}>
|
||||
{oidcLoading ? t("admin.saving") : t("admin.saveConfiguration")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResetConfig}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("admin.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
src/ui/desktop/apps/admin/tabs/RolesTab.tsx
Normal file
300
src/ui/desktop/apps/admin/tabs/RolesTab.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Textarea } from "@/components/ui/textarea.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { Shield, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
type Role,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
export function RolesTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Create/Edit Role Dialog
|
||||
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
|
||||
const [editingRole, setEditingRole] = React.useState<Role | null>(null);
|
||||
const [roleName, setRoleName] = React.useState("");
|
||||
const [roleDisplayName, setRoleDisplayName] = React.useState("");
|
||||
const [roleDescription, setRoleDescription] = React.useState("");
|
||||
|
||||
// Load roles
|
||||
const loadRoles = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getRoles();
|
||||
setRoles(response.roles || []);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToLoadRoles"));
|
||||
console.error("Failed to load roles:", error);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
// Create role
|
||||
const handleCreateRole = () => {
|
||||
setEditingRole(null);
|
||||
setRoleName("");
|
||||
setRoleDisplayName("");
|
||||
setRoleDescription("");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
// Edit role
|
||||
const handleEditRole = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
setRoleName(role.name);
|
||||
setRoleDisplayName(role.displayName);
|
||||
setRoleDescription(role.description || "");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
// Save role
|
||||
const handleSaveRole = async () => {
|
||||
if (!roleDisplayName.trim()) {
|
||||
toast.error(t("rbac.roleDisplayNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingRole && !roleName.trim()) {
|
||||
toast.error(t("rbac.roleNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
// Update existing role
|
||||
await updateRole(editingRole.id, {
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleUpdatedSuccessfully"));
|
||||
} else {
|
||||
// Create new role
|
||||
await createRole({
|
||||
name: roleName,
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleCreatedSuccessfully"));
|
||||
}
|
||||
|
||||
setRoleDialogOpen(false);
|
||||
loadRoles();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToSaveRole"));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete role
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmDeleteRole"),
|
||||
description: t("rbac.confirmDeleteRoleDescription", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
confirmText: t("common.delete"),
|
||||
cancelText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteRole(role.id);
|
||||
toast.success(t("rbac.roleDeletedSuccessfully"));
|
||||
loadRoles();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToDeleteRole"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Roles Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t("rbac.roleManagement")}
|
||||
</h3>
|
||||
<Button onClick={handleCreateRole}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rbac.createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("rbac.roleName")}</TableHead>
|
||||
<TableHead>{t("rbac.displayName")}</TableHead>
|
||||
<TableHead>{t("rbac.description")}</TableHead>
|
||||
<TableHead>{t("rbac.type")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noRoles")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono">{role.name}</TableCell>
|
||||
<TableCell>{t(role.displayName)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{role.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.isSystem ? (
|
||||
<Badge variant="secondary">{t("rbac.systemRole")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("rbac.customRole")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{!role.isSystem && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditRole(role)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteRole(role)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Role Dialog */}
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingRole
|
||||
? t("rbac.editRoleDescription")
|
||||
: t("rbac.createRoleDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{!editingRole && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-name">{t("rbac.roleName")}</Label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={roleName}
|
||||
onChange={(e) => setRoleName(e.target.value.toLowerCase())}
|
||||
placeholder="developer"
|
||||
disabled={!!editingRole}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("rbac.roleNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-display-name">{t("rbac.displayName")}</Label>
|
||||
<Input
|
||||
id="role-display-name"
|
||||
value={roleDisplayName}
|
||||
onChange={(e) => setRoleDisplayName(e.target.value)}
|
||||
placeholder={t("rbac.displayNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-description">{t("rbac.description")}</Label>
|
||||
<Textarea
|
||||
id="role-description"
|
||||
value={roleDescription}
|
||||
onChange={(e) => setRoleDescription(e.target.value)}
|
||||
placeholder={t("rbac.descriptionPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveRole}>
|
||||
{editingRole ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
src/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx
Normal file
213
src/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getCookie,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
jwtToken: string;
|
||||
isRevoked?: boolean;
|
||||
}
|
||||
|
||||
interface SessionManagementTabProps {
|
||||
sessions: Session[];
|
||||
sessionsLoading: boolean;
|
||||
fetchSessions: () => void;
|
||||
}
|
||||
|
||||
export function SessionManagementTab({
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
fetchSessions,
|
||||
}: SessionManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
const currentJWT = getCookie("jwt");
|
||||
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
||||
const isCurrentSession = currentSession?.id === sessionId;
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeSession"),
|
||||
async () => {
|
||||
try {
|
||||
await revokeSession(sessionId);
|
||||
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||
|
||||
if (isCurrentSession) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSession"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeAllSessions"),
|
||||
async () => {
|
||||
try {
|
||||
const data = await revokeAllUserSessions(userId);
|
||||
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||
fetchSessions();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.sessionManagement")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={fetchSessions}
|
||||
disabled={sessionsLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingSessions")}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.noActiveSessions")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.device")}</TableHead>
|
||||
<TableHead>{t("admin.user")}</TableHead>
|
||||
<TableHead>{t("admin.created")}</TableHead>
|
||||
<TableHead>{t("admin.lastActive")}</TableHead>
|
||||
<TableHead>{t("admin.expires")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(session.lastActiveAt);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={session.isRevoked ? "opacity-50" : undefined}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
{t("admin.revoked")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(session.userId)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title={t("admin.revokeAllUserSessionsTitle")}
|
||||
>
|
||||
{t("admin.revokeAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
src/ui/desktop/apps/admin/tabs/UserManagementTab.tsx
Normal file
177
src/ui/desktop/apps/admin/tabs/UserManagementTab.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { UserPlus, Edit, Trash2, Link2, Unlink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { deleteUser } from "@/ui/main-axios.ts";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
}
|
||||
|
||||
interface UserManagementTabProps {
|
||||
users: User[];
|
||||
usersLoading: boolean;
|
||||
allowPasswordLogin: boolean;
|
||||
fetchUsers: () => void;
|
||||
onCreateUser: () => void;
|
||||
onEditUser: (user: User) => void;
|
||||
onLinkOIDCUser: (user: { id: string; username: string }) => void;
|
||||
onUnlinkOIDC: (userId: string, username: string) => void;
|
||||
}
|
||||
|
||||
export function UserManagementTab({
|
||||
users,
|
||||
usersLoading,
|
||||
allowPasswordLogin,
|
||||
fetchUsers,
|
||||
onCreateUser,
|
||||
onEditUser,
|
||||
onLinkOIDCUser,
|
||||
onUnlinkOIDC,
|
||||
}: UserManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const getAuthTypeDisplay = (user: User): string => {
|
||||
if (user.is_oidc && user.password_hash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.is_oidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUserQuick = async (username: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.deleteUser", { username }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||
fetchUsers();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToDeleteUser"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userManagement")}</h3>
|
||||
<div className="flex gap-2">
|
||||
{allowPasswordLogin && (
|
||||
<Button onClick={onCreateUser} size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
{t("admin.createUser")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={fetchUsers}
|
||||
disabled={usersLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{usersLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingUsers")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.username")}</TableHead>
|
||||
<TableHead>{t("admin.authType")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getAuthTypeDisplay(user)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditUser(user)}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title={t("admin.manageUser")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUnlinkOIDC(user.id, user.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title="Unlink OIDC (keep password only)"
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUserQuick(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user