Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions scripts/test-acting-as.mjs
Original file line number Diff line number Diff line change
@@ -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.");
74 changes: 74 additions & 0 deletions src/common/actingAs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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);
}
8 changes: 8 additions & 0 deletions src/components/Home/BaseNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" }}
</button>
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/components/Portal/CalendarBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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));
})
Expand All @@ -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;
Expand Down
55 changes: 53 additions & 2 deletions src/components/Portal/RightBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@
<div class="right-bar portal_sidebar" :class="{ active: sidebar_open }" @click="show_if_inactive">
<div class="sidebar_overflow">
<div class="sidebar_first_block auth-action can-logout doprompt">
<div class="linked_acc_icon" v-if="$store && $store.personal_account">
<div
class="linked_acc_chip"
v-if="$store && $store.is_acting_as_linked && $store.acting_as_label"
:title="$store.acting_as_label"
:aria-label="$store.acting_as_label"
role="status"
>
<img
class="linked_acc_chip__img"
width="24"
height="24"
src="@/assets/img/general/user-linked.png"
alt=""
/>
<span class="linked_acc_chip__text">{{ $store.acting_as_label }}</span>
</div>
<div class="linked_acc_icon" v-else-if="$store && $store.personal_account">
<img
class="linked_acc_icon__img"
width="24"
Expand Down Expand Up @@ -130,6 +146,7 @@ export default {
border-radius: calc(var(--radius-sidebar) - 10px);
}
.linked_acc_icon,
.linked_acc_chip,
.active_acc_icon {
flex: 0 0 30px;
height: 30px;
Expand All @@ -138,9 +155,43 @@ export default {
align-items: center;
justify-content: center;
}
.linked_acc_icon {
.linked_acc_icon,
.linked_acc_chip {
filter: var(--filter-icon);
}
.linked_acc_chip {
flex: 1 1 auto;
width: auto;
min-width: 0;
max-width: none;
height: auto;
min-height: 30px;
gap: 6px;
justify-content: flex-start;
padding: 4px 8px 4px 4px;
margin-right: 4px;
border-radius: calc(var(--radius-sidebar) - var(--padding-sidebar) / 2);
background: var(--color-on-bg-alt);
filter: none;
}
.linked_acc_chip__img {
flex: 0 0 24px;
filter: var(--filter-icon);
}
.linked_acc_chip__text {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
font-weight: 600;
line-height: 1.25;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar_first_block:has(.linked_acc_chip) .auth_logout {
flex: 0 0 auto;
}
.active_acc_icon {
border-radius: calc(var(--radius-sidebar) - var(--padding-sidebar) / 2);
overflow: hidden;
Expand Down
Loading
Loading