From 48bd11a8fa4707971502d87a3ac12aba4eb17961 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 00:42:50 +0000 Subject: [PATCH] P2: Acting-as chip + principal prefs for linked accounts Show "Acting as [school email]" in portal RightBar/Admin/Settings (and BaseNav tooltip) when school_uid claim or personal+linked_to. Write calendar prefs to active school doc; refresh ID token claims after acceptLink; add acting-as unit tests to CI. Co-authored-by: Sander Vonk --- .github/workflows/ci.yml | 3 + package.json | 1 + scripts/test-acting-as.mjs | 164 ++++++++++++++++++++++++ src/common/actingAs.ts | 74 +++++++++++ src/components/Home/BaseNav.vue | 8 ++ src/components/Portal/CalendarBlock.vue | 4 +- src/components/Portal/RightBar.vue | 55 +++++++- src/store/index.ts | 109 ++++++++++++++-- src/views/Admin/Admin.vue | 49 ++++++- src/views/Portal/SettingsModal.vue | 78 +++++++---- 10 files changed, 508 insertions(+), 37 deletions(-) create mode 100644 scripts/test-acting-as.mjs create mode 100644 src/common/actingAs.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73045e00..3689f9b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,5 +40,8 @@ jobs: - name: Board hydrate tests run: npm run test:me-board + - name: Acting-as chip tests + run: npm run test:acting-as + - name: Build run: npm run build diff --git a/package.json b/package.json index 23ba0f2d..ef713496 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:paths": "node --experimental-strip-types scripts/test-paths.mjs", "test:class-listeners": "node --experimental-strip-types scripts/test-class-listeners.mjs", "test:me-board": "node --experimental-strip-types scripts/test-me-board.mjs", + "test:acting-as": "node --experimental-strip-types scripts/test-acting-as.mjs", "deploy": "node deploy.js", "postbuild": "cross-env OS_TYPE=$(uname -s) npm-run-all --parallel copy-files echo-message", "copy-files": "npm run copy-win || npm run copy-nix", diff --git a/scripts/test-acting-as.mjs b/scripts/test-acting-as.mjs new file mode 100644 index 00000000..a5552d4b --- /dev/null +++ b/scripts/test-acting-as.mjs @@ -0,0 +1,164 @@ +/** + * Unit tests for linked-account Acting-as chip visibility + school email resolution. + * Run: node --experimental-strip-types scripts/test-acting-as.mjs + */ +import { + actingAsLabel, + isActingAsLinked, + resolveActingAsEmail, + schoolUidFromClaims, + shouldShowActingAsChip, +} from "../src/common/actingAs.ts"; + +let failed = 0; + +function assert(cond, msg) { + if (!cond) { + failed++; + console.error("FAIL:", msg); + } else { + console.log("ok:", msg); + } +} + +function assertEq(actual, expected, label) { + const ok = actual === expected; + if (!ok) { + failed++; + console.error(`FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); + } else { + console.log(`ok: ${label}`); + } +} + +console.log("--- school_uid claim parse ---\n"); + +assertEq(schoolUidFromClaims(null), null, "null claims"); +assertEq(schoolUidFromClaims(undefined), null, "undefined claims"); +assertEq(schoolUidFromClaims({}), null, "empty claims"); +assertEq(schoolUidFromClaims({ school_uid: "" }), null, "empty school_uid"); +assertEq(schoolUidFromClaims({ school_uid: " " }), null, "whitespace school_uid"); +assertEq(schoolUidFromClaims({ school_uid: 123 }), null, "non-string school_uid"); +assertEq(schoolUidFromClaims({ school_uid: "schoolUidABC" }), "schoolUidABC", "valid school_uid"); + +console.log("\n--- isActingAsLinked ---\n"); + +assert( + !isActingAsLinked({ personalAccount: false, linkedTo: null, schoolUidClaim: null }), + "org login, no claim → not acting as" +); +assert( + !isActingAsLinked({ personalAccount: true, linkedTo: null, schoolUidClaim: null }), + "personal without linked_to → not acting as" +); +assert( + isActingAsLinked({ + personalAccount: true, + linkedTo: "schoolUidABC", + schoolUidClaim: null, + }), + "personal + linked_to fallback → acting as" +); +assert( + isActingAsLinked({ + personalAccount: false, + linkedTo: null, + schoolUidClaim: "schoolUidABC", + }), + "school_uid claim alone → acting as (prefer claim)" +); +assert( + isActingAsLinked({ + personalAccount: true, + linkedTo: "schoolUidABC", + schoolUidClaim: "schoolUidABC", + }), + "claim + personal linked → acting as" +); + +console.log("\n--- resolveActingAsEmail / chip visibility ---\n"); + +assertEq( + resolveActingAsEmail({ + personalAccount: false, + linkedTo: null, + schoolUidClaim: null, + activeDocEmail: "student@andrew.cmu.edu", + userEmail: "student@andrew.cmu.edu", + }), + null, + "org login → no acting-as email" +); + +assertEq( + resolveActingAsEmail({ + personalAccount: true, + linkedTo: "schoolUidABC", + schoolUidClaim: "schoolUidABC", + activeDocEmail: "student@andrew.cmu.edu", + userEmail: "me@gmail.com", + }), + "student@andrew.cmu.edu", + "linked session → school email, not personal Gmail" +); + +assertEq( + resolveActingAsEmail({ + personalAccount: true, + linkedTo: "schoolUidABC", + schoolUidClaim: null, + activeDocEmail: null, + userEmail: "me@gmail.com", + }), + null, + "acting as but school email missing → null (never personal Gmail)" +); + +assertEq( + resolveActingAsEmail({ + personalAccount: true, + linkedTo: "schoolUidABC", + schoolUidClaim: "schoolUidABC", + activeDocEmail: "me@gmail.com", + userEmail: "me@gmail.com", + }), + null, + "guard: active email === personal Gmail → unresolved" +); + +assert( + shouldShowActingAsChip({ + personalAccount: true, + linkedTo: "schoolUidABC", + schoolUidClaim: "schoolUidABC", + activeDocEmail: "student@andrew.cmu.edu", + userEmail: "me@gmail.com", + }), + "chip visible for linked personal with school email" +); + +assert( + !shouldShowActingAsChip({ + personalAccount: false, + linkedTo: null, + schoolUidClaim: null, + activeDocEmail: "student@andrew.cmu.edu", + userEmail: "student@andrew.cmu.edu", + }), + "chip hidden for non-linked org login" +); + +assertEq( + actingAsLabel("student@andrew.cmu.edu"), + "Acting as student@andrew.cmu.edu", + "label format" +); +assertEq(actingAsLabel(null), null, "label null when no email"); +assertEq(actingAsLabel(" "), null, "label null when blank"); + +console.log("\n--- done ---\n"); +if (failed) { + console.error(`${failed} assertion(s) failed`); + process.exit(1); +} +console.log("All assertions passed."); diff --git a/src/common/actingAs.ts b/src/common/actingAs.ts new file mode 100644 index 00000000..b264a075 --- /dev/null +++ b/src/common/actingAs.ts @@ -0,0 +1,74 @@ +/** + * Linked-account "Acting as …" chip helpers. + * Pure leaf module (no Firebase / Vue) so node tests can load it directly. + * + * Prefer ID-token `school_uid` claim; fall back to personal_account + linked_to. + * School email always comes from the principal (active/school) doc — never personal Gmail. + * + * @module common/actingAs + */ + +/** Loose claims bag from Firebase getIdTokenResult().claims */ +export type IdTokenClaims = Record | null | undefined; + +export interface ActingAsInput { + /** Custom claim school_uid when present */ + schoolUidClaim?: string | null; + /** users/{auth.uid}.personal_account */ + personalAccount?: boolean; + /** users/{auth.uid}.linked_to (school uid) */ + linkedTo?: string | null; + /** School principal email from active_doc / linked school doc */ + activeDocEmail?: string | null; + /** Signed-in Firebase user email (may be personal Gmail) — never used as chip email when personal */ + userEmail?: string | null; +} + +/** Read school_uid from ID token claims (string only). */ +export function schoolUidFromClaims(claims: IdTokenClaims): string | null { + if (!claims || typeof claims !== "object") return null; + const raw = claims.school_uid; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed || null; +} + +/** + * True when this session should show Acting-as chrome: + * prefer school_uid claim; else personal_account && linked_to. + */ +export function isActingAsLinked(input: ActingAsInput): boolean { + const claim = (input.schoolUidClaim || "").trim(); + if (claim) return true; + const linkedTo = (input.linkedTo || "").trim(); + return !!(input.personalAccount && linkedTo); +} + +/** + * School principal email for the chip label. + * Uses active_doc.email only — never the personal Gmail when acting as linked. + * Returns null when not acting as, or when school email is unavailable. + */ +export function resolveActingAsEmail(input: ActingAsInput): string | null { + if (!isActingAsLinked(input)) return null; + const schoolEmail = (input.activeDocEmail || "").trim(); + if (!schoolEmail) return null; + // Guard: if somehow active email equals personal Gmail while personal, treat as unresolved + if (input.personalAccount && input.userEmail) { + const personal = input.userEmail.trim().toLowerCase(); + if (personal && schoolEmail.toLowerCase() === personal) return null; + } + return schoolEmail; +} + +/** Full accessible label, e.g. "Acting as student@andrew.cmu.edu". */ +export function actingAsLabel(email: string | null | undefined): string | null { + const trimmed = (email || "").trim(); + if (!trimmed) return null; + return `Acting as ${trimmed}`; +} + +/** Chip should render when acting-as session has a resolvable school email. */ +export function shouldShowActingAsChip(input: ActingAsInput): boolean { + return !!resolveActingAsEmail(input); +} diff --git a/src/components/Home/BaseNav.vue b/src/components/Home/BaseNav.vue index 66a31f29..4958f138 100644 --- a/src/components/Home/BaseNav.vue +++ b/src/components/Home/BaseNav.vue @@ -45,6 +45,8 @@ id="nav-auth-btn" class="small-action-btn auth-action can-logout can-login click-action" :class="{ linked: $store.personal_account && $store.user, oauth: !logged_in }" + :title="auth_btn_title" + :aria-label="auth_btn_title || undefined" > Log {{ logged_in ? "Out" : "In" }} @@ -80,6 +82,12 @@ export default { logged_in() { return !!this.$store.user; }, + auth_btn_title() { + if (!this.logged_in) return "Log in"; + if (this.$store.acting_as_label) return this.$store.acting_as_label; + if (this.$store.user?.email) return `Logged in as ${this.$store.user.email}`; + return "Log out"; + }, }, }; // using the mounted() hook to add an event listener diff --git a/src/components/Portal/CalendarBlock.vue b/src/components/Portal/CalendarBlock.vue index af7bf099..7dacc6b0 100644 --- a/src/components/Portal/CalendarBlock.vue +++ b/src/components/Portal/CalendarBlock.vue @@ -362,7 +362,7 @@ export default { get_day_tasks(day) { return this.tasks .filter((task) => { - if (this.$store?.account_doc?.prefs?.hide_finished && this.is_completed(task)) return false; + if (this.$store?.active_doc?.prefs?.hide_finished && this.is_completed(task)) return false; const task_date = compatDateObj(task.date); return this.day_matches(task_date, day) && (!this.filtered_classes.length || this.filtered_classes.includes(task.class_id)); }) @@ -372,7 +372,7 @@ export default { if (!this.is_completed(a) && this.is_completed(b)) return -1; // prioritize/deprioritize notes based on user settings if (a.type != b.type) { - let prioritize_notes = !this.$store?.account_doc?.prefs?.derank_notes; + let prioritize_notes = !this.$store?.active_doc?.prefs?.derank_notes; if (prioritize_notes && a.type == "note") return -1; if (prioritize_notes && b.type == "note") return 1; if (a.type == "note" && b.type != "note") return 1; diff --git a/src/components/Portal/RightBar.vue b/src/components/Portal/RightBar.vue index b84de6af..52250615 100644 --- a/src/components/Portal/RightBar.vue +++ b/src/components/Portal/RightBar.vue @@ -2,7 +2,23 @@