Mercury/ui/src/views/admin/Users.vue
Matthew L McPeak 271cfda84c
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 2m59s
ci / publish (push) Successful in 2m24s
Initial Commit
2026-06-17 21:46:25 -04:00

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" label="+ New User" />
</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)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteUser(u.id)" label="Delete" />
</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 label="Save Changes" />
</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 label="Create User" />
</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>