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"; 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. // 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. // By the time any component mounts, the plugin has gated auth, so `auth` is authenticated.
// `auth.fetch` auto-attaches the Bearer access token. // `auth.fetch` auto-attaches the Bearer access token.
@@ -16,7 +26,7 @@ export function useAuth() {
} catch { } catch {
// non-JSON error body // non-JSON error body
} }
throw new Error(message); throw new ApiError(message, res.status);
} }
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
return (await res.json()) as T; return (await res.json()) as T;

View File

@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ApiError } from "~/composables/useAuth";
interface List { interface List {
id: string; id: string;
name: string; name: string;
ownerId?: string | null;
} }
interface Task { interface Task {
id: string; id: string;
@@ -10,6 +13,7 @@ interface Task {
description: string | null; description: string | null;
source: string; source: string;
consumed: boolean; consumed: boolean;
ownerId?: string | null;
createdAt: string; createdAt: string;
} }
@@ -22,6 +26,21 @@ const loadingLists = ref(true);
const loadingTasks = ref(false); const loadingTasks = ref(false);
const error = ref<string | null>(null); 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 title = ref("");
const description = ref(""); const description = ref("");
const showNote = ref(false); const showNote = ref(false);
@@ -43,12 +62,12 @@ async function refreshLists() {
loadingLists.value = true; loadingLists.value = true;
error.value = null; error.value = null;
try { 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)) { if (lists.value.length && !lists.value.some((l) => l.id === selectedId.value)) {
await selectList(lists.value[0]!.id); await selectList(lists.value[0]!.id);
} }
} catch (e) { } catch (e) {
error.value = (e as Error).message; handleApiError(e);
} finally { } finally {
loadingLists.value = false; loadingLists.value = false;
} }
@@ -66,9 +85,9 @@ async function refreshTasks() {
loadingTasks.value = true; loadingTasks.value = true;
error.value = null; error.value = null;
try { try {
tasks.value = await api<Task[]>(`/lists/${selectedId.value}/tasks`); tasks.value = (await api<Task[]>(`/lists/${selectedId.value}/tasks`)).filter(mine);
} catch (e) { } catch (e) {
error.value = (e as Error).message; handleApiError(e);
} finally { } finally {
loadingTasks.value = false; loadingTasks.value = false;
} }
@@ -95,7 +114,7 @@ async function addTask() {
showNote.value = false; showNote.value = false;
titleInput.value?.focus(); titleInput.value?.focus();
} catch (e) { } catch (e) {
error.value = (e as Error).message; handleApiError(e);
} finally { } finally {
adding.value = false; adding.value = false;
} }
@@ -135,7 +154,19 @@ onMounted(() => {
<main class="content"> <main class="content">
<p v-if="error" class="error" role="alert">{{ error }}</p> <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> <p class="muted">Loading lists…</p>
</template> </template>
@@ -172,7 +203,7 @@ onMounted(() => {
</template> </template>
</main> </main>
<footer v-if="lists.length" class="dock"> <footer v-if="lists.length && !missingRole" class="dock">
<button <button
v-if="lists.length > 1" v-if="lists.length > 1"
class="list-pill" class="list-pill"