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:
2026-06-28 17:53:44 -04:00
parent 1db0c3b37a
commit af1de3d140
14 changed files with 1232 additions and 4 deletions
+143
View File
@@ -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>
)}
</>
);
}