198 lines
7.8 KiB
Vue
198 lines
7.8 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<div class="page-title">
|
|
<h2>Users</h2>
|
|
<span class="subtitle">Manage API access accounts and their permission roles</span>
|
|
</div>
|
|
<NychButton @click="showCreate = true">+ New User</NychButton>
|
|
</div>
|
|
|
|
<div class="table-card">
|
|
<div class="table-card-header">
|
|
<span class="count">{{ users.length }} {{ users.length === 1 ? 'user' : 'users' }}</span>
|
|
</div>
|
|
<template v-if="users.length"><div class="table-scroll"><table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Username</th>
|
|
<th>Role</th>
|
|
<th>Mask</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="u in users" :key="u.id">
|
|
<td class="id-cell">{{ u.id }}</td>
|
|
<td class="username-cell">{{ u.username }}</td>
|
|
<td><span class="role-badge" :class="roleBadgeClass(u.permissions_mask)">{{ roleName(u.permissions_mask) }}</span></td>
|
|
<td><code>{{ u.permissions_mask }}</code></td>
|
|
<td class="date-cell">{{ new Date(u.created_at).toLocaleDateString() }}</td>
|
|
<td class="actions-cell">
|
|
<NychButton size="small" @click="openEdit(u)">Edit</NychButton>
|
|
<NychButton size="small" severity="danger" @click="deleteUser(u.id)">Delete</NychButton>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table></div></template>
|
|
<div class="empty-state" v-else>
|
|
<span class="empty-icon">◉</span>
|
|
<span class="empty-label">No users yet</span>
|
|
<span class="empty-hint">Create the first user to grant API access.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.username}`" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
|
|
<form @submit.prevent="submitEdit" class="dialog-form">
|
|
<div class="field">
|
|
<label>Username</label>
|
|
<NychInputText v-model="editForm.username" fluid autocomplete="off" />
|
|
</div>
|
|
<div class="field">
|
|
<label>New password <span class="optional">(leave blank to keep current)</span></label>
|
|
<NychInputText v-model="editForm.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Role</label>
|
|
<NychSelect v-model="editForm.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" fluid />
|
|
<p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
|
|
</div>
|
|
<NychButton type="submit" fluid>Save Changes</NychButton>
|
|
</form>
|
|
</NychDialog>
|
|
|
|
<NychDialog v-model:visible="showCreate" header="New User" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
|
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
|
<div class="field">
|
|
<label>Username</label>
|
|
<NychInputText v-model="form.username" placeholder="username" fluid autocomplete="off" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Password</label>
|
|
<NychInputText v-model="form.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Role</label>
|
|
<NychSelect
|
|
v-model="selectedRole"
|
|
:options="ROLES"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
placeholder="Select a role"
|
|
fluid
|
|
/>
|
|
<p class="hint">{{ roleDescription }}</p>
|
|
</div>
|
|
<NychButton type="submit" fluid>Create User</NychButton>
|
|
</form>
|
|
</NychDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
|
|
const auth = useAuthStore()
|
|
const users = ref<any[]>([])
|
|
const showCreate = ref(false)
|
|
const showEdit = ref(false)
|
|
const editId = ref<number | null>(null)
|
|
const editForm = ref({ username: '', password: '', permissions_mask: '1' })
|
|
const form = ref({ username: '', password: '' })
|
|
const selectedRole = ref('1')
|
|
|
|
const ROLES = [
|
|
{ label: 'Viewer', value: '1', description: 'Read-only access to API data' },
|
|
{ label: 'Editor', value: '7', description: 'Read, write, and delete via API' },
|
|
{ label: 'Query Admin', value: '15', description: 'Editor + manage SQL query registry' },
|
|
{ label: 'Ops Admin', value: '23', description: 'Editor + manage query cache' },
|
|
{ label: 'Admin', value: '31', description: 'All above combined' },
|
|
{ label: 'Super Admin', value: '63', description: 'Full access including users, permissions, and blacklist' },
|
|
]
|
|
|
|
const roleDescription = computed(() =>
|
|
ROLES.find(r => r.value === selectedRole.value)?.description ?? ''
|
|
)
|
|
|
|
function roleName(mask: string) {
|
|
return ROLES.find(r => r.value === mask)?.label ?? 'Custom'
|
|
}
|
|
|
|
function roleBadgeClass(mask: string) {
|
|
const map: Record<string, string> = {
|
|
'1': 'role-viewer', '7': 'role-editor',
|
|
'15': 'role-query', '23': 'role-ops',
|
|
'31': 'role-admin', '63': 'role-super',
|
|
}
|
|
return map[mask] ?? 'role-custom'
|
|
}
|
|
|
|
async function load() {
|
|
const res = await fetch('/api/users', { headers: auth.authHeaders() })
|
|
users.value = await res.json()
|
|
}
|
|
|
|
async function submitCreate() {
|
|
await fetch('/api/users', {
|
|
method: 'POST',
|
|
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...form.value, permissions_mask: selectedRole.value }),
|
|
})
|
|
showCreate.value = false
|
|
form.value = { username: '', password: '' }
|
|
selectedRole.value = '1'
|
|
load()
|
|
}
|
|
|
|
function openEdit(u: any) {
|
|
editId.value = u.id
|
|
editForm.value = { username: u.username, password: '', permissions_mask: u.permissions_mask }
|
|
showEdit.value = true
|
|
}
|
|
|
|
async function submitEdit() {
|
|
const body: any = { username: editForm.value.username, permissions_mask: editForm.value.permissions_mask }
|
|
if (editForm.value.password) body.password = editForm.value.password
|
|
await fetch(`/api/users/${editId.value}`, {
|
|
method: 'PUT',
|
|
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
showEdit.value = false
|
|
load()
|
|
}
|
|
|
|
async function deleteUser(id: number) {
|
|
if (!confirm('Delete this user?')) return
|
|
await fetch(`/api/users/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
|
load()
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.id-cell { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; }
|
|
.username-cell { font-weight: 600; color: var(--text-high); }
|
|
.date-cell { color: var(--text-muted); font-size: 0.82rem; font-family: var(--font-mono); }
|
|
|
|
.role-badge {
|
|
display: inline-block;
|
|
padding: 0.2rem 0.65rem;
|
|
border-radius: 20px;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}
|
|
.role-viewer { background: var(--info-subtle); color: var(--info); border: 1px solid var(--info-border); }
|
|
.role-editor { background: var(--success-subtle); color: var(--success); border: 1px solid var(--success-border); }
|
|
.role-query { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
|
.role-ops { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
|
.role-admin { background: color-mix(in srgb, var(--primary) 12%, var(--surface-1)); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); }
|
|
.role-super { background: var(--primary); color: var(--primary-fg); border: 1px solid var(--primary); }
|
|
.role-custom { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
|
|
</style>
|