feat(web): filter by ownerId and surface missing-role 401 state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 08:28:25 +00:00
parent 03fbe06a04
commit f8955be4e9
2 changed files with 49 additions and 8 deletions

View File

@@ -1,5 +1,15 @@
import type { ZitadelAuth } from "@kuns/zitadel-auth";
/** API call failure carrying the HTTP status (401 = authenticated but not authorized). */
export class ApiError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
// Access the bootstrap-provided Zitadel auth instance + a small JSON helper for /api calls.
// By the time any component mounts, the plugin has gated auth, so `auth` is authenticated.
// `auth.fetch` auto-attaches the Bearer access token.
@@ -16,7 +26,7 @@ export function useAuth() {
} catch {
// non-JSON error body
}
throw new Error(message);
throw new ApiError(message, res.status);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { ApiError } from "~/composables/useAuth";
interface List {
id: string;
name: string;
ownerId?: string | null;
}
interface Task {
id: string;
@@ -10,6 +13,7 @@ interface Task {
description: string | null;
source: string;
consumed: boolean;
ownerId?: string | null;
createdAt: string;
}
@@ -22,6 +26,21 @@ const loadingLists = ref(true);
const loadingTasks = ref(false);
const error = ref<string | null>(null);
// Authenticated but the token lacks the required Zitadel project role → API answers 401.
const missingRole = ref(false);
// Defense-in-depth: the server already scopes every query by the token's sub; additionally
// hide anything not owned by the current user. Absent ownerId = legacy (pre-multi-user) row.
const mine = (x: { ownerId?: string | null }) => !x.ownerId || x.ownerId === auth.user?.sub;
function handleApiError(e: unknown) {
if (e instanceof ApiError && e.status === 401) {
missingRole.value = true;
} else {
error.value = (e as Error).message;
}
}
const title = ref("");
const description = ref("");
const showNote = ref(false);
@@ -43,12 +62,12 @@ async function refreshLists() {
loadingLists.value = true;
error.value = null;
try {
lists.value = await api<List[]>("/lists");
lists.value = (await api<List[]>("/lists")).filter(mine);
if (lists.value.length && !lists.value.some((l) => l.id === selectedId.value)) {
await selectList(lists.value[0]!.id);
}
} catch (e) {
error.value = (e as Error).message;
handleApiError(e);
} finally {
loadingLists.value = false;
}
@@ -66,9 +85,9 @@ async function refreshTasks() {
loadingTasks.value = true;
error.value = null;
try {
tasks.value = await api<Task[]>(`/lists/${selectedId.value}/tasks`);
tasks.value = (await api<Task[]>(`/lists/${selectedId.value}/tasks`)).filter(mine);
} catch (e) {
error.value = (e as Error).message;
handleApiError(e);
} finally {
loadingTasks.value = false;
}
@@ -95,7 +114,7 @@ async function addTask() {
showNote.value = false;
titleInput.value?.focus();
} catch (e) {
error.value = (e as Error).message;
handleApiError(e);
} finally {
adding.value = false;
}
@@ -135,7 +154,19 @@ onMounted(() => {
<main class="content">
<p v-if="error" class="error" role="alert">{{ error }}</p>
<template v-if="loadingLists">
<template v-if="missingRole">
<div class="empty">
<p class="empty-mark"></p>
<p class="empty-title">No access yet</p>
<p class="muted">
You're signed in, but your account is missing the “user” role for ClaudeDo.
Ask the admin to grant it in Zitadel, then sign in again.
</p>
<button class="link" @click="auth.logout()">Sign out</button>
</div>
</template>
<template v-else-if="loadingLists">
<p class="muted">Loading lists…</p>
</template>
@@ -172,7 +203,7 @@ onMounted(() => {
</template>
</main>
<footer v-if="lists.length" class="dock">
<footer v-if="lists.length && !missingRole" class="dock">
<button
v-if="lists.length > 1"
class="list-pill"