Phase 6: Permissions, Push, WebAuthn scaffolding
Backend: - internal/permissions/permissions.go: permission bitflags (VIEW_CHANNEL through SHARE_SCREEN) - internal/permissions/checker.go: permission checker with role aggregation - internal/middleware/permission.go: RequirePermission middleware - internal/push/handlers.go: push subscription CRUD, VAPID key endpoint, SendPush - internal/auth/webauthn.go: WebAuthn passkey scaffolding (begin/finish endpoints) - internal/db/db.go: is_default on roles, push_subscriptions table, webauthn_credentials table - go.mod: added webpush-go, go-webauthn dependencies Frontend: - stores/role.ts: Zustand store for role management - RoleManager.tsx: role CRUD with permission checkboxes - MemberRoleAssign.tsx: assign roles to members - App.tsx: /servers/:serverId/roles route
This commit is contained in:
@@ -5,6 +5,7 @@ import { ChatArea } from './components/ChatArea.tsx';
|
||||
import { UserSettings } from './components/UserSettings.tsx';
|
||||
import { BotManager } from './components/BotManager.tsx';
|
||||
import { CommandManager } from './components/CommandManager.tsx';
|
||||
import { RoleManager } from './components/RoleManager.tsx';
|
||||
import { useAuthStore } from './stores/auth.ts';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -71,6 +72,24 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/servers/:serverId/roles"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
← [BACK]
|
||||
</Link>
|
||||
<span className="text-gb-orange font-mono text-sm">ROLE MANAGER</span>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<RoleManager />
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||
import type { User } from '../stores/auth.ts';
|
||||
|
||||
interface MemberRoleAssignProps {
|
||||
serverId: string;
|
||||
member: User;
|
||||
allMembers?: User[];
|
||||
}
|
||||
|
||||
export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||
const setMemberRoles = useRoleStore((s) => s.setMemberRoles);
|
||||
const getMemberRoles = useRoleStore((s) => s.getMemberRoles);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [memberRoles, setMemberRolesState] = useState<Role[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId) fetchRoles(serverId);
|
||||
}, [serverId, fetchRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !serverId) return;
|
||||
(async () => {
|
||||
const mRoles = await getMemberRoles(serverId, member.id);
|
||||
setMemberRolesState(mRoles);
|
||||
setSelectedIds(new Set(mRoles.map((r) => r.id)));
|
||||
})();
|
||||
}, [open, serverId, member.id, getMemberRoles]);
|
||||
|
||||
const assignableRoles = roles.filter((r) => !r.is_default);
|
||||
|
||||
const handleToggle = (roleId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(roleId)) {
|
||||
next.delete(roleId);
|
||||
} else {
|
||||
next.add(roleId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!serverId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await setMemberRoles(serverId, member.id, Array.from(selectedIds));
|
||||
setMemberRolesState(roles.filter((r) => selectedIds.has(r.id)));
|
||||
setOpen(false);
|
||||
} catch {
|
||||
// error in store
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inline role badges + trigger */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{memberRoles.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="inline-block px-1.5 py-0.5 text-xs font-mono border border-gb-bg-t"
|
||||
style={{ color: r.color || '#ebdbb2', borderColor: r.color || undefined }}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[ROLES]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<p className="text-gb-orange font-mono text-sm mb-1">
|
||||
{'>'} ASSIGN ROLES
|
||||
</p>
|
||||
<p className="text-gb-fg-f font-mono text-xs mb-4">
|
||||
member: <span className="text-gb-aqua">{member.display_name || member.username}</span>
|
||||
</p>
|
||||
|
||||
{assignableRoles.length === 0 ? (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[no roles available]</p>
|
||||
) : (
|
||||
<div className="space-y-2 mb-4">
|
||||
{assignableRoles.map((role) => (
|
||||
<label
|
||||
key={role.id}
|
||||
className="flex items-center gap-3 cursor-pointer font-mono text-xs p-2 border border-gb-bg-t hover:bg-gb-bg-s transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(role.id)}
|
||||
onChange={() => handleToggle(role.id)}
|
||||
className="accent-gb-orange"
|
||||
/>
|
||||
<span
|
||||
className="inline-block w-3 h-3 border border-gb-bg-t shrink-0"
|
||||
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||
/>
|
||||
<span style={{ color: role.color || '#ebdbb2' }}>{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="terminal-button text-xs"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '[SAVING...]' : '[SAVE]'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||
>
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useRoleStore, type Role, type CreateRoleData, type UpdateRoleData } from '../stores/role.ts';
|
||||
|
||||
// Permission bitflags
|
||||
const PERMS = {
|
||||
VIEW_CHANNEL: 1,
|
||||
SEND_MESSAGES: 2,
|
||||
MANAGE_MESSAGES: 4,
|
||||
KICK_MEMBERS: 8,
|
||||
BAN_MEMBERS: 16,
|
||||
MANAGE_SERVER: 32,
|
||||
MANAGE_CHANNELS: 64,
|
||||
ADMINISTRATOR: 128,
|
||||
CONNECT_VOICE: 256,
|
||||
SPEAK_VOICE: 512,
|
||||
SHARE_SCREEN: 1024,
|
||||
} as const;
|
||||
|
||||
const PERM_CATEGORIES = [
|
||||
{
|
||||
name: 'General',
|
||||
perms: [
|
||||
{ key: 'VIEW_CHANNEL', label: 'View Channel', flag: PERMS.VIEW_CHANNEL },
|
||||
{ key: 'SEND_MESSAGES', label: 'Send Messages', flag: PERMS.SEND_MESSAGES },
|
||||
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', flag: PERMS.MANAGE_MESSAGES },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Moderation',
|
||||
perms: [
|
||||
{ key: 'KICK_MEMBERS', label: 'Kick Members', flag: PERMS.KICK_MEMBERS },
|
||||
{ key: 'BAN_MEMBERS', label: 'Ban Members', flag: PERMS.BAN_MEMBERS },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Server',
|
||||
perms: [
|
||||
{ key: 'MANAGE_SERVER', label: 'Manage Server', flag: PERMS.MANAGE_SERVER },
|
||||
{ key: 'MANAGE_CHANNELS', label: 'Manage Channels', flag: PERMS.MANAGE_CHANNELS },
|
||||
{ key: 'ADMINISTRATOR', label: 'Administrator', flag: PERMS.ADMINISTRATOR },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
perms: [
|
||||
{ key: 'CONNECT_VOICE', label: 'Connect Voice', flag: PERMS.CONNECT_VOICE },
|
||||
{ key: 'SPEAK_VOICE', label: 'Speak Voice', flag: PERMS.SPEAK_VOICE },
|
||||
{ key: 'SHARE_SCREEN', label: 'Share Screen', flag: PERMS.SHARE_SCREEN },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const GRUVBOX_COLORS = [
|
||||
'#fb4934', '#b8bb26', '#fabd2f', '#83a598',
|
||||
'#d3869b', '#8ec07c', '#fe8019', '#ebdbb2',
|
||||
'#a89984', '#928374', '#282828',
|
||||
];
|
||||
|
||||
function hasPerm(permissions: number, flag: number): boolean {
|
||||
return (permissions & flag) !== 0;
|
||||
}
|
||||
|
||||
function permSummary(permissions: number): string {
|
||||
if (hasPerm(permissions, PERMS.ADMINISTRATOR)) return 'ADMINISTRATOR';
|
||||
const active: string[] = [];
|
||||
if (hasPerm(permissions, PERMS.MANAGE_SERVER)) active.push('MGR_SERVER');
|
||||
if (hasPerm(permissions, PERMS.MANAGE_CHANNELS)) active.push('MGR_CHAN');
|
||||
if (hasPerm(permissions, PERMS.MANAGE_MESSAGES)) active.push('MGR_MSG');
|
||||
if (hasPerm(permissions, PERMS.KICK_MEMBERS)) active.push('KICK');
|
||||
if (hasPerm(permissions, PERMS.BAN_MEMBERS)) active.push('BAN');
|
||||
return active.length > 0 ? active.join('+') : 'BASIC';
|
||||
}
|
||||
|
||||
interface RoleFormProps {
|
||||
serverId: string;
|
||||
role?: Role;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function RoleForm({ serverId, role, onClose }: RoleFormProps) {
|
||||
const createRole = useRoleStore((s) => s.createRole);
|
||||
const updateRole = useRoleStore((s) => s.updateRole);
|
||||
const loading = useRoleStore((s) => s.loading);
|
||||
|
||||
const [name, setName] = useState(role?.name ?? '');
|
||||
const [color, setColor] = useState(role?.color ?? '#ebdbb2');
|
||||
const [position, setPosition] = useState(role?.position ?? 0);
|
||||
const [permissions, setPermissions] = useState(role?.permissions ?? 0);
|
||||
|
||||
const togglePerm = useCallback((flag: number) => {
|
||||
setPermissions((prev) => prev ^ flag);
|
||||
}, []);
|
||||
|
||||
const handleAdminToggle = useCallback(() => {
|
||||
setPermissions((prev) => {
|
||||
if (hasPerm(prev, PERMS.ADMINISTRATOR)) return 0;
|
||||
return 0xFFFFFFFF; // grant all
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
if (role) {
|
||||
const data: UpdateRoleData = { name: name.trim(), color, permissions, position };
|
||||
await updateRole(serverId, role.id, data);
|
||||
} else {
|
||||
const data: CreateRoleData = { name: name.trim(), color, permissions, position };
|
||||
await createRole(serverId, data);
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
// error in store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<p className="text-gb-orange font-mono text-sm mb-4">
|
||||
{'>'} {role ? 'EDIT ROLE' : 'CREATE ROLE'}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, 64))}
|
||||
maxLength={64}
|
||||
className="terminal-input w-full"
|
||||
placeholder="role-name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">COLOR:</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{GRUVBOX_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-7 h-7 border-2 transition-all ${
|
||||
color === c ? 'border-gb-fg scale-110' : 'border-gb-bg-t'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="terminal-input w-full text-xs"
|
||||
placeholder="#ebdbb2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">POSITION:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={position}
|
||||
onChange={(e) => setPosition(parseInt(e.target.value) || 0)}
|
||||
className="terminal-input w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-2 font-mono text-xs">PERMISSIONS:</label>
|
||||
<div className="space-y-3">
|
||||
{/* Administrator all-or-nothing */}
|
||||
<div className="border border-gb-bg-t p-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer font-mono text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasPerm(permissions, PERMS.ADMINISTRATOR)}
|
||||
onChange={handleAdminToggle}
|
||||
className="accent-gb-red"
|
||||
/>
|
||||
<span className="text-gb-red">Administrator (grants all permissions)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Category groups */}
|
||||
{!hasPerm(permissions, PERMS.ADMINISTRATOR) &&
|
||||
PERM_CATEGORIES.filter((cat) => cat.name !== 'Server' || true).map((cat) => (
|
||||
<div key={cat.name} className="border border-gb-bg-t p-3">
|
||||
<p className="text-gb-aqua font-mono text-xs mb-2">[{cat.name.toUpperCase()}]</p>
|
||||
<div className="space-y-1">
|
||||
{cat.perms
|
||||
.filter((p) => p.key !== 'ADMINISTRATOR')
|
||||
.map((p) => (
|
||||
<label
|
||||
key={p.key}
|
||||
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasPerm(permissions, p.flag)}
|
||||
onChange={() => togglePerm(p.flag)}
|
||||
className="accent-gb-orange"
|
||||
/>
|
||||
<span className="text-gb-fg">{p.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" className="terminal-button" disabled={loading || !name.trim()}>
|
||||
{loading ? '[SAVING...]' : '[SAVE]'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||
>
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleManager() {
|
||||
const { serverId } = useParams<{ serverId: string }>();
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const loading = useRoleStore((s) => s.loading);
|
||||
const error = useRoleStore((s) => s.error);
|
||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||
const deleteRole = useRoleStore((s) => s.deleteRole);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId) fetchRoles(serverId);
|
||||
}, [serverId, fetchRoles]);
|
||||
|
||||
const handleDelete = async (role: Role) => {
|
||||
if (!serverId) return;
|
||||
if (!window.confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteRole(serverId, role.id);
|
||||
} catch {
|
||||
// error in store
|
||||
}
|
||||
};
|
||||
|
||||
if (!serverId) {
|
||||
return (
|
||||
<div className="h-full w-full bg-gb-bg p-8 flex items-center justify-center">
|
||||
<p className="text-gb-red font-mono">ERR: no server selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="border border-gb-bg-t p-6">
|
||||
<pre className="text-gb-orange font-mono text-center mb-6">
|
||||
{'┌──────────────────────────────────┐\n'}
|
||||
{'│ === ROLE MANAGER === │\n'}
|
||||
{'└──────────────────────────────────┘'}
|
||||
</pre>
|
||||
|
||||
{error && (
|
||||
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingRole(null); setShowForm(true); }}
|
||||
className="terminal-button"
|
||||
>
|
||||
[CREATE ROLE]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[loading roles...]</p>
|
||||
)}
|
||||
{!loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[no roles configured]</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{roles.map((role) => (
|
||||
<div key={role.id} className="border border-gb-bg-t p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
className="inline-block w-4 h-4 border border-gb-bg-t shrink-0"
|
||||
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||
/>
|
||||
<span
|
||||
className="font-mono text-sm font-bold truncate"
|
||||
style={{ color: role.color || '#ebdbb2' }}
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
{role.is_default && (
|
||||
<span className="text-gb-fg-f font-mono text-xs">[DEFAULT]</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f font-mono text-xs ml-auto shrink-0">
|
||||
POS:{role.position}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gb-fg-f font-mono text-xs mb-3">
|
||||
perms: {permSummary(role.permissions)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingRole(role); setShowForm(true); }}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[EDIT]
|
||||
</button>
|
||||
{!role.is_default && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(role)}
|
||||
className="terminal-button text-xs hover:!text-gb-red"
|
||||
>
|
||||
[DELETE]
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
{'<'} [BACK TO CHAT]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<RoleForm
|
||||
serverId={serverId}
|
||||
role={editingRole ?? undefined}
|
||||
onClose={() => { setShowForm(false); setEditingRole(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
server_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
permissions: number;
|
||||
position: number;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface CreateRoleData {
|
||||
name: string;
|
||||
color: string;
|
||||
permissions: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface UpdateRoleData {
|
||||
name?: string;
|
||||
color?: string;
|
||||
permissions?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface RoleState {
|
||||
roles: Role[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
fetchRoles: (serverId: string) => Promise<void>;
|
||||
createRole: (serverId: string, data: CreateRoleData) => Promise<Role>;
|
||||
updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise<Role>;
|
||||
deleteRole: (serverId: string, roleId: string) => Promise<void>;
|
||||
setMemberRoles: (serverId: string, userId: string, roleIds: string[]) => Promise<void>;
|
||||
getMemberRoles: (serverId: string, userId: string) => Promise<Role[]>;
|
||||
}
|
||||
|
||||
export const useRoleStore = create<RoleState>((set) => ({
|
||||
roles: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchRoles: async (serverId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const roles = await api.get<Role[]>(`/servers/${serverId}/roles`);
|
||||
set({ roles: roles.sort((a, b) => b.position - a.position), loading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch roles',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createRole: async (serverId, data) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const role = await api.post<Role>(`/servers/${serverId}/roles`, data);
|
||||
set((state) => ({
|
||||
roles: [...state.roles, role].sort((a, b) => b.position - a.position),
|
||||
loading: false,
|
||||
}));
|
||||
return role;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create role',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateRole: async (serverId, roleId, data) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const role = await api.patch<Role>(`/servers/${serverId}/roles/${roleId}`, data);
|
||||
set((state) => ({
|
||||
roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position),
|
||||
loading: false,
|
||||
}));
|
||||
return role;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update role',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteRole: async (serverId, roleId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await api.delete(`/servers/${serverId}/roles/${roleId}`);
|
||||
set((state) => ({
|
||||
roles: state.roles.filter((r) => r.id !== roleId),
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete role',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setMemberRoles: async (serverId, userId, roleIds) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await api.put(`/servers/${serverId}/members/${userId}/roles`, { role_ids: roleIds });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to set member roles',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getMemberRoles: async (serverId, userId) => {
|
||||
try {
|
||||
return await api.get<Role[]>(`/servers/${serverId}/members/${userId}/roles`);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to get member roles',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user