first commit
This commit is contained in:
1 parent
abb09fbea8
commit
72f6d49818
355 files changed
+121239
-3
No files matched your search
@@ -0,0 +1,326 @@
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { Clock, Edit3, Loader2, Lock, Shield, Trash2 } from "lucide-react";
|
||||
import type { UserRecord } from "@/lib/api";
|
||||
import { getRoleMeta } from "@/app/users/components/user-role-config";
|
||||
import {
|
||||
formatRelativeDate,
|
||||
getInitials,
|
||||
getServerAccessSummary,
|
||||
} from "@/app/users/components/user-utils";
|
||||
|
||||
interface ActiveUsersPanelProps {
|
||||
loading: boolean;
|
||||
items: UserRecord[];
|
||||
currentUserId: string | undefined;
|
||||
canManageAccess: boolean;
|
||||
canManageRoles: boolean;
|
||||
mutatingKey: string | null;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startItem: number;
|
||||
endItem: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onEditAccess: (user: UserRecord) => void;
|
||||
onEditRole: (user: UserRecord) => void;
|
||||
onDelete: (user: UserRecord) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ActiveUsersPanel({
|
||||
loading,
|
||||
items,
|
||||
currentUserId,
|
||||
canManageAccess,
|
||||
canManageRoles,
|
||||
mutatingKey,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
startItem,
|
||||
endItem,
|
||||
onPageChange,
|
||||
onEditAccess,
|
||||
onEditRole,
|
||||
onDelete,
|
||||
}: ActiveUsersPanelProps) {
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 32,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Loading users...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No active users found.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Server Access</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th style={{ textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const role = getRoleMeta(item.role);
|
||||
const RoleIcon = role.icon;
|
||||
const isSelf = currentUserId === item.id;
|
||||
const access = getServerAccessSummary({
|
||||
role: item.role,
|
||||
allServersAccess: item.allServersAccess,
|
||||
names: item.serverAssignments.map(
|
||||
(assignment) => assignment.server.name,
|
||||
),
|
||||
});
|
||||
const canEditAccess =
|
||||
canManageAccess &&
|
||||
!(item.role === "SUPER_ADMIN" && currentUserId !== undefined);
|
||||
const canEditRole =
|
||||
canManageRoles && item.role !== "SUPER_ADMIN";
|
||||
const canDeactivate =
|
||||
canManageRoles && item.role !== "SUPER_ADMIN" && !isSelf;
|
||||
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background:
|
||||
"linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{getInitials(item.name)}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
<p
|
||||
style={{ fontSize: 11, color: "var(--text-muted)" }}
|
||||
>
|
||||
{item.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
background: `${role.color}15`,
|
||||
color: role.color,
|
||||
border: `1px solid ${role.color}25`,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RoleIcon size={10} /> {role.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: access.tone,
|
||||
color: access.color,
|
||||
border: `1px solid ${access.border}`,
|
||||
padding: "2px 7px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{access.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
background: item.isActive
|
||||
? "rgba(16,185,129,0.1)"
|
||||
: "rgba(100,116,139,0.1)",
|
||||
color: item.isActive ? "#10b981" : "#64748b",
|
||||
border: `1px solid ${item.isActive ? "rgba(16,185,129,0.3)" : "rgba(100,116,139,0.3)"}`,
|
||||
padding: "2px 9px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{item.isActive ? (
|
||||
<span
|
||||
className="animate-pulse-dot"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: "50%",
|
||||
background: "currentColor",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{item.isActive ? "active" : "inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
}}
|
||||
>
|
||||
<Clock
|
||||
size={10}
|
||||
style={{
|
||||
display: "inline",
|
||||
marginRight: 4,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
/>
|
||||
{formatRelativeDate(item.lastLogin)}
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Server access"
|
||||
onClick={() => onEditAccess(item)}
|
||||
disabled={
|
||||
!canEditAccess ||
|
||||
mutatingKey === `access:${item.id}`
|
||||
}
|
||||
>
|
||||
{mutatingKey === `access:${item.id}` ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Shield size={11} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Edit role"
|
||||
onClick={() => onEditRole(item)}
|
||||
disabled={
|
||||
!canEditRole || mutatingKey === `role:${item.id}`
|
||||
}
|
||||
>
|
||||
{mutatingKey === `role:${item.id}` ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Edit3 size={11} />
|
||||
)}
|
||||
</button>
|
||||
{canDeactivate ? (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Delete user"
|
||||
onClick={() => void onDelete(item)}
|
||||
disabled={mutatingKey === `delete:${item.id}`}
|
||||
>
|
||||
{mutatingKey === `delete:${item.id}` ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={11} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: "5px 8px",
|
||||
opacity: 0.7,
|
||||
cursor: "default",
|
||||
}}
|
||||
title={
|
||||
item.role === "SUPER_ADMIN"
|
||||
? "Protected Super Admin account"
|
||||
: isSelf
|
||||
? "Current user"
|
||||
: "Locked action"
|
||||
}
|
||||
disabled
|
||||
>
|
||||
<Lock size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startItem={startItem}
|
||||
endItem={endItem}
|
||||
itemLabel="users"
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { Clock, Lock } from "lucide-react";
|
||||
import type { UserRecord } from "@/lib/api";
|
||||
import { getRoleMeta } from "@/app/users/components/user-role-config";
|
||||
import {
|
||||
formatRelativeDate,
|
||||
getInitials,
|
||||
getServerAccessSummary,
|
||||
} from "@/app/users/components/user-utils";
|
||||
|
||||
interface ArchivedUsersPanelProps {
|
||||
loading: boolean;
|
||||
items: UserRecord[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startItem: number;
|
||||
endItem: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function ArchivedUsersPanel({
|
||||
loading,
|
||||
items,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
startItem,
|
||||
endItem,
|
||||
onPageChange,
|
||||
}: ArchivedUsersPanelProps) {
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Lock size={14} style={{ color: "var(--text-secondary)" }} />
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Archived / Inactive Users
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Legacy accounts that were previously deactivated remain visible here
|
||||
for reference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 28,
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Loading archived users...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 28,
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No archived users.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Server Access</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const role = getRoleMeta(item.role);
|
||||
const RoleIcon = role.icon;
|
||||
const access = getServerAccessSummary({
|
||||
role: item.role,
|
||||
allServersAccess: item.allServersAccess,
|
||||
names: item.serverAssignments.map(
|
||||
(assignment) => assignment.server.name,
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(100,116,139,0.9), rgba(71,85,105,0.9))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{getInitials(item.name)}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
<p
|
||||
style={{ fontSize: 11, color: "var(--text-muted)" }}
|
||||
>
|
||||
{item.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
background: `${role.color}15`,
|
||||
color: role.color,
|
||||
border: `1px solid ${role.color}25`,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RoleIcon size={10} /> {role.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: access.tone,
|
||||
color: access.color,
|
||||
border: `1px solid ${access.border}`,
|
||||
padding: "2px 7px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{access.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
background: "rgba(100,116,139,0.1)",
|
||||
color: "#64748b",
|
||||
border: "1px solid rgba(100,116,139,0.3)",
|
||||
padding: "2px 9px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
inactive
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
}}
|
||||
>
|
||||
<Clock
|
||||
size={10}
|
||||
style={{
|
||||
display: "inline",
|
||||
marginRight: 4,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
/>
|
||||
{formatRelativeDate(item.lastLogin)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startItem={startItem}
|
||||
endItem={endItem}
|
||||
itemLabel="archived users"
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Edit3, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { UserRecord, UserRole } from "@/lib/api";
|
||||
import { manageableRoles } from "@/app/users/components/user-role-config";
|
||||
|
||||
interface EditRoleModalProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
onSubmit: (role: Exclude<UserRole, "SUPER_ADMIN">) => Promise<void>;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export default function EditRoleModal({
|
||||
user,
|
||||
onClose,
|
||||
onSubmit,
|
||||
submitting,
|
||||
error,
|
||||
}: EditRoleModalProps) {
|
||||
const [role, setRole] = useState<Exclude<UserRole, "SUPER_ADMIN">>(
|
||||
user.role as Exclude<UserRole, "SUPER_ADMIN">,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
style={{ maxWidth: 420 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Edit Role
|
||||
</h3>
|
||||
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}>
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
color: "#ef4444",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{manageableRoles.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<label
|
||||
key={item.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
background: "var(--bg-input)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${role === item.id ? item.color : "var(--border)"}`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
checked={role === item.id}
|
||||
name="role"
|
||||
onChange={() =>
|
||||
setRole(item.id as Exclude<UserRole, "SUPER_ADMIN">)
|
||||
}
|
||||
style={{ accentColor: item.color }}
|
||||
/>
|
||||
<Icon size={13} style={{ color: item.color }} />
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
|
||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => void onSubmit(role)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Edit3 size={12} />
|
||||
)}
|
||||
Save Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CheckCircle, Copy, Key, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
CreateUserInvitationBody,
|
||||
Server as ServerRecord,
|
||||
} from "@/lib/api";
|
||||
import ServerAccessSelector from "@/app/users/components/ServerAccessSelector";
|
||||
import { manageableRoles } from "@/app/users/components/user-role-config";
|
||||
import {
|
||||
copyText,
|
||||
formatRelativeDate,
|
||||
} from "@/app/users/components/user-utils";
|
||||
|
||||
interface InviteUserModalProps {
|
||||
availableServers: ServerRecord[];
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: CreateUserInvitationBody) => Promise<void>;
|
||||
onCopySuccess: () => void;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
result: { inviteUrl: string; email: string; expiresAt: string } | null;
|
||||
}
|
||||
|
||||
export default function InviteUserModal({
|
||||
availableServers,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onCopySuccess,
|
||||
submitting,
|
||||
error,
|
||||
result,
|
||||
}: InviteUserModalProps) {
|
||||
const [form, setForm] = useState<CreateUserInvitationBody>({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "DEVELOPER",
|
||||
allServersAccess: true,
|
||||
serverIds: [],
|
||||
expiresInDays: 7,
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await onSubmit(form);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!result) return;
|
||||
await copyText(result.inviteUrl);
|
||||
setCopied(true);
|
||||
onCopySuccess();
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
style={{ maxWidth: 560 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Invite User
|
||||
</h3>
|
||||
<p
|
||||
style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}
|
||||
>
|
||||
Generate an onboarding link so the user sets their own password.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
background: "rgba(16,185,129,0.08)",
|
||||
border: "1px solid rgba(16,185,129,0.28)",
|
||||
borderRadius: 10,
|
||||
padding: "12px 14px",
|
||||
color: "#10b981",
|
||||
}}
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
<div>
|
||||
<p style={{ fontSize: 12, fontWeight: 700 }}>
|
||||
Invitation link ready
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
Send this link to {result.email}. It expires{" "}
|
||||
{formatRelativeDate(result.expiresAt)}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-input)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Invitation Link
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-primary)",
|
||||
wordBreak: "break-all",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{result.inviteUrl}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ flex: 1 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{copied ? <CheckCircle size={12} /> : <Copy size={12} />}
|
||||
{copied ? "Copied" : "Copy Invite Link"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
>
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
color: "#ef4444",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 5,
|
||||
display: "block",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Jane Doe"
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 5,
|
||||
display: "block",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="user@example.com"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
email: event.target.value,
|
||||
}))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 5,
|
||||
display: "block",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.role}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
role: event.target
|
||||
.value as CreateUserInvitationBody["role"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{manageableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 5,
|
||||
display: "block",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Link Expiry
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.expiresInDays ?? 7}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
expiresInDays: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value={3}>3 days</option>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={14}>14 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 8,
|
||||
display: "block",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Server Access
|
||||
</label>
|
||||
<ServerAccessSelector
|
||||
availableServers={availableServers}
|
||||
allServersAccess={form.allServersAccess}
|
||||
selectedServerIds={form.serverIds}
|
||||
onAllServersChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
allServersAccess: value,
|
||||
serverIds: value ? [] : current.serverIds,
|
||||
}))
|
||||
}
|
||||
onSelectionChange={(value) =>
|
||||
setForm((current) => ({ ...current, serverIds: value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ flex: 1 }}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Key size={12} />
|
||||
)}
|
||||
Generate Invite
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { CheckCircle, Key, Loader2, Trash2, Users } from "lucide-react";
|
||||
import type { UserInvitationRecord } from "@/lib/api";
|
||||
import { getRoleMeta } from "@/app/users/components/user-role-config";
|
||||
import {
|
||||
formatRelativeDate,
|
||||
getServerAccessSummary,
|
||||
} from "@/app/users/components/user-utils";
|
||||
|
||||
interface PendingInvitesPanelProps {
|
||||
loading: boolean;
|
||||
items: UserInvitationRecord[];
|
||||
copiedInviteId: string | null;
|
||||
mutatingKey: string | null;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startItem: number;
|
||||
endItem: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onCopyFreshInviteLink: (invitationId: string) => void | Promise<void>;
|
||||
onRevokeInvitation: (invitationId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function PendingInvitesPanel({
|
||||
loading,
|
||||
items,
|
||||
copiedInviteId,
|
||||
mutatingKey,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
startItem,
|
||||
endItem,
|
||||
onPageChange,
|
||||
onCopyFreshInviteLink,
|
||||
onRevokeInvitation,
|
||||
}: PendingInvitesPanelProps) {
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Users size={14} style={{ color: "var(--text-secondary)" }} />
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Pending Invitations
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Copy a fresh invite link or revoke an unused invitation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 28,
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Loading invitations...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 28,
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No pending invitations.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invitee</th>
|
||||
<th>Role</th>
|
||||
<th>Server Access</th>
|
||||
<th>Expires</th>
|
||||
<th style={{ textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((invitation) => {
|
||||
const role = getRoleMeta(invitation.role);
|
||||
const InvitationIcon = role.icon;
|
||||
const access = getServerAccessSummary({
|
||||
role: invitation.role,
|
||||
allServersAccess: invitation.allServersAccess,
|
||||
names: invitation.servers.map((server) => server.name),
|
||||
});
|
||||
|
||||
return (
|
||||
<tr key={invitation.id}>
|
||||
<td>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{invitation.name}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{invitation.email}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
background: `${role.color}15`,
|
||||
color: role.color,
|
||||
border: `1px solid ${role.color}25`,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<InvitationIcon size={10} /> {role.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: access.tone,
|
||||
color: access.color,
|
||||
border: `1px solid ${access.border}`,
|
||||
padding: "2px 7px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{access.label}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{formatRelativeDate(invitation.expiresAt)}
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Copy fresh invite link"
|
||||
onClick={() =>
|
||||
void onCopyFreshInviteLink(invitation.id)
|
||||
}
|
||||
disabled={
|
||||
mutatingKey === `invite-link:${invitation.id}`
|
||||
}
|
||||
>
|
||||
{mutatingKey === `invite-link:${invitation.id}` ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : copiedInviteId === invitation.id ? (
|
||||
<CheckCircle size={11} />
|
||||
) : (
|
||||
<Key size={11} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Revoke invitation"
|
||||
onClick={() => void onRevokeInvitation(invitation.id)}
|
||||
disabled={
|
||||
mutatingKey === `revoke-invite:${invitation.id}`
|
||||
}
|
||||
>
|
||||
{mutatingKey === `revoke-invite:${invitation.id}` ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={11} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startItem={startItem}
|
||||
endItem={endItem}
|
||||
itemLabel="invitations"
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Loader2, Shield } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { Server as ServerRecord, UserRecord } from "@/lib/api";
|
||||
import ServerAccessSelector from "@/app/users/components/ServerAccessSelector";
|
||||
|
||||
interface ServerAccessModalProps {
|
||||
user: UserRecord;
|
||||
availableServers: ServerRecord[];
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: {
|
||||
allServersAccess: boolean;
|
||||
serverIds: string[];
|
||||
}) => Promise<void>;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export default function ServerAccessModal({
|
||||
user,
|
||||
availableServers,
|
||||
onClose,
|
||||
onSubmit,
|
||||
submitting,
|
||||
error,
|
||||
}: ServerAccessModalProps) {
|
||||
const [allServersAccess, setAllServersAccess] = useState(
|
||||
user.role === "SUPER_ADMIN" ? true : user.allServersAccess,
|
||||
);
|
||||
const [serverIds, setServerIds] = useState(
|
||||
user.serverAssignments.map((assignment) => assignment.serverId),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
style={{ maxWidth: 600 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Server Access
|
||||
</h3>
|
||||
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}>
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
color: "#ef4444",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{user.role === "SUPER_ADMIN" ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(239,68,68,0.25)",
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Super Admin always has access to all servers.
|
||||
</div>
|
||||
) : (
|
||||
<ServerAccessSelector
|
||||
availableServers={availableServers}
|
||||
allServersAccess={allServersAccess}
|
||||
selectedServerIds={serverIds}
|
||||
onAllServersChange={(value) => {
|
||||
setAllServersAccess(value);
|
||||
if (value) setServerIds([]);
|
||||
}}
|
||||
onSelectionChange={setServerIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
|
||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => void onSubmit({ allServersAccess, serverIds })}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Shield size={12} />
|
||||
)}
|
||||
Save Access
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import type { Server as ServerRecord } from "@/lib/api";
|
||||
|
||||
interface ServerAccessSelectorProps {
|
||||
availableServers: ServerRecord[];
|
||||
allServersAccess: boolean;
|
||||
selectedServerIds: string[];
|
||||
onAllServersChange: (value: boolean) => void;
|
||||
onSelectionChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
export default function ServerAccessSelector({
|
||||
availableServers,
|
||||
allServersAccess,
|
||||
selectedServerIds,
|
||||
onAllServersChange,
|
||||
onSelectionChange,
|
||||
}: ServerAccessSelectorProps) {
|
||||
const toggleServer = (serverId: string) => {
|
||||
if (selectedServerIds.includes(serverId)) {
|
||||
onSelectionChange(
|
||||
selectedServerIds.filter((value) => value !== serverId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSelectionChange([...selectedServerIds, serverId]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => onAllServersChange(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderColor: allServersAccess ? "rgba(16,185,129,0.35)" : undefined,
|
||||
color: allServersAccess ? "#10b981" : undefined,
|
||||
background: allServersAccess ? "rgba(16,185,129,0.08)" : undefined,
|
||||
}}
|
||||
>
|
||||
All Servers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => onAllServersChange(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderColor: !allServersAccess
|
||||
? "rgba(59,130,246,0.35)"
|
||||
: undefined,
|
||||
color: !allServersAccess ? "#3b82f6" : undefined,
|
||||
background: !allServersAccess ? "rgba(59,130,246,0.08)" : undefined,
|
||||
}}
|
||||
>
|
||||
Selected Servers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!allServersAccess ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 8,
|
||||
maxHeight: 220,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{availableServers.map((server) => {
|
||||
const checked = selectedServerIds.includes(server.id);
|
||||
return (
|
||||
<label
|
||||
key={server.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
background: checked
|
||||
? "rgba(59,130,246,0.08)"
|
||||
: "var(--bg-input)",
|
||||
border: `1px solid ${checked ? "rgba(59,130,246,0.28)" : "var(--border)"}`,
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleServer(server.id)}
|
||||
style={{ accentColor: "#3b82f6" }}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{server.name}
|
||||
</p>
|
||||
<p style={{ fontSize: 10, color: "var(--text-muted)" }}>
|
||||
{server.ip}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { roles } from "@/app/users/components/user-role-config";
|
||||
import type { UserRole } from "@/lib/api";
|
||||
|
||||
interface UsersRoleSummaryProps {
|
||||
roleCounts: Record<UserRole, number>;
|
||||
}
|
||||
|
||||
export default function UsersRoleSummary({
|
||||
roleCounts,
|
||||
}: UsersRoleSummaryProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{roles.map((role) => {
|
||||
const Icon = role.icon;
|
||||
return (
|
||||
<div key={role.id} className="card" style={{ padding: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: `${role.color}15`,
|
||||
border: `1px solid ${role.color}25`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Icon size={15} style={{ color: role.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{role.label}
|
||||
</p>
|
||||
<p style={{ fontSize: 10, color: "var(--text-muted)" }}>
|
||||
{roleCounts[role.id]} user(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{role.desc}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Clock, Loader2, Lock, Plus, RefreshCw, Users } from "lucide-react";
|
||||
|
||||
export type UsersTabKey = "active-users" | "pending-invites" | "archived-users";
|
||||
|
||||
interface UsersToolbarProps {
|
||||
activeTab: UsersTabKey;
|
||||
activeUsersCount: number;
|
||||
invitationsCount: number;
|
||||
archivedUsersCount: number;
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
canInvite: boolean;
|
||||
onTabChange: (tab: UsersTabKey) => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onInvite: () => void;
|
||||
}
|
||||
|
||||
export default function UsersToolbar({
|
||||
activeTab,
|
||||
activeUsersCount,
|
||||
invitationsCount,
|
||||
archivedUsersCount,
|
||||
loading,
|
||||
refreshing,
|
||||
canInvite,
|
||||
onTabChange,
|
||||
onRefresh,
|
||||
onInvite,
|
||||
}: UsersToolbarProps) {
|
||||
const tabs = [
|
||||
{
|
||||
key: "active-users" as const,
|
||||
label: `Active Users (${activeUsersCount})`,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
key: "pending-invites" as const,
|
||||
label: `Pending Invites (${invitationsCount})`,
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
key: "archived-users" as const,
|
||||
label: `Archived Users (${archivedUsersCount})`,
|
||||
icon: Lock,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
background: "var(--bg-card)",
|
||||
borderRadius: 10,
|
||||
padding: 4,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "7px 16px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
background:
|
||||
activeTab === tab.key
|
||||
? "rgba(59,130,246,0.15)"
|
||||
: "transparent",
|
||||
color: activeTab === tab.key ? "#3b82f6" : "var(--text-muted)",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void onRefresh()}
|
||||
disabled={loading || refreshing}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Refresh
|
||||
</button>
|
||||
{canInvite ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={onInvite}
|
||||
>
|
||||
<Plus size={12} /> Invite User
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Crown, Shield, User } from "lucide-react";
|
||||
import type { UserRole } from "@/lib/api";
|
||||
|
||||
export const roles: Array<{
|
||||
id: UserRole;
|
||||
label: string;
|
||||
color: string;
|
||||
icon: typeof Crown;
|
||||
desc: string;
|
||||
}> = [
|
||||
{
|
||||
id: "SUPER_ADMIN",
|
||||
label: "Super Admin",
|
||||
color: "#ef4444",
|
||||
icon: Crown,
|
||||
desc: "Full access to everything",
|
||||
},
|
||||
{
|
||||
id: "OPERATOR",
|
||||
label: "Operator",
|
||||
color: "#f59e0b",
|
||||
icon: Shield,
|
||||
desc: "Manage operational workflows",
|
||||
},
|
||||
{
|
||||
id: "DEVELOPER",
|
||||
label: "Developer",
|
||||
color: "#3b82f6",
|
||||
icon: User,
|
||||
desc: "Deploy and operate assigned workloads",
|
||||
},
|
||||
{
|
||||
id: "VIEWER",
|
||||
label: "Viewer",
|
||||
color: "#64748b",
|
||||
icon: User,
|
||||
desc: "Read-only visibility",
|
||||
},
|
||||
];
|
||||
|
||||
export const manageableRoles = roles.filter(
|
||||
(role) => role.id !== "SUPER_ADMIN",
|
||||
);
|
||||
|
||||
export function getRoleMeta(roleId: UserRole) {
|
||||
return roles.find((role) => role.id === roleId) ?? roles[3];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { UserRole } from "@/lib/api";
|
||||
|
||||
export function getInitials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function formatRelativeDate(value: string | null) {
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Never";
|
||||
|
||||
const diffSeconds = Math.max(
|
||||
0,
|
||||
Math.floor((Date.now() - date.getTime()) / 1000),
|
||||
);
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||
if (diffSeconds < 604800) return `${Math.floor(diffSeconds / 86400)}d ago`;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function getServerAccessSummary(input: {
|
||||
role: UserRole;
|
||||
allServersAccess: boolean;
|
||||
names: string[];
|
||||
}) {
|
||||
if (input.role === "SUPER_ADMIN" || input.allServersAccess) {
|
||||
return {
|
||||
label: "All servers",
|
||||
color: "#10b981",
|
||||
tone: "rgba(16,185,129,0.1)",
|
||||
border: "rgba(16,185,129,0.25)",
|
||||
};
|
||||
}
|
||||
if (input.names.length === 0) {
|
||||
return {
|
||||
label: "No servers assigned",
|
||||
color: "#f59e0b",
|
||||
tone: "rgba(245,158,11,0.1)",
|
||||
border: "rgba(245,158,11,0.25)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: input.names.join(", "),
|
||||
color: "#3b82f6",
|
||||
tone: "rgba(59,130,246,0.08)",
|
||||
border: "rgba(59,130,246,0.2)",
|
||||
};
|
||||
}
|
||||
|
||||
export function copyText(value: string) {
|
||||
return navigator.clipboard.writeText(value).catch(() => undefined);
|
||||
}
|
||||
Reference in new issue
Block a user