diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d8159a93..ac4d04dd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,5 +46,8 @@ jobs:
- name: Repeating edit-scope tests
run: npm run test:repeating-scope
+ - name: Join-class ref tests
+ run: npm run test:join-class
+
- name: Build
run: npm run build
diff --git a/.gitignore b/.gitignore
index 448815d5..0f8167e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ node_modules
/server
# local env files
+.env
.env.local
.env.*.local
diff --git a/package.json b/package.json
index 2c3d0b75..87f5bc1e 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"test:me-board": "node --experimental-strip-types scripts/test-me-board.mjs",
"test:acting-as": "node --experimental-strip-types scripts/test-acting-as.mjs",
"test:repeating-scope": "node --experimental-strip-types scripts/test-repeating-scope.mjs",
+ "test:join-class": "node --experimental-strip-types scripts/test-join-class.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-join-class.mjs b/scripts/test-join-class.mjs
new file mode 100644
index 00000000..1adaae77
--- /dev/null
+++ b/scripts/test-join-class.mjs
@@ -0,0 +1,83 @@
+/**
+ * Unit tests for P4 join-ref → bare classId resolution (Add Class / Onboarding).
+ * Run: node --experimental-strip-types scripts/test-join-class.mjs
+ * (imports parseJoinRef from ../src/common/paths.ts)
+ */
+import { parseJoinRef } from "../src/common/paths.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 = JSON.stringify(actual) === JSON.stringify(expected);
+ if (!ok) {
+ failed++;
+ console.error(
+ `FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`
+ );
+ } else {
+ console.log(`ok: ${label}`);
+ }
+}
+
+const org = "mvla.net";
+const orgAt = "@mvla.net";
+
+console.log("--- parseJoinRef (P4 flat enroll) ---\n");
+
+assertEq(parseJoinRef(null, org), null, "null → null");
+assertEq(parseJoinRef("", org), null, "empty → null");
+assertEq(parseJoinRef(" ", org), null, "whitespace → null");
+
+assertEq(
+ parseJoinRef("abc123", org),
+ { classId: "abc123", needsTeacherLookup: true },
+ "bare classId needs teacher dual-read"
+);
+
+assertEq(
+ parseJoinRef("t@mvla.net/abc123", org),
+ { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
+ "email/classId → bare classId + teacher"
+);
+
+assertEq(
+ parseJoinRef("t@mvla.net~abc123", org),
+ { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
+ "email~classId → bare classId + teacher"
+);
+
+assertEq(
+ parseJoinRef("t~abc123", org),
+ { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
+ "local~classId expands org domain"
+);
+
+assertEq(
+ parseJoinRef("t~abc123", orgAt),
+ { classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
+ "local~classId with @orgDomain store form"
+);
+
+assertEq(
+ parseJoinRef(" teacher@mvla.net/cls9 ", org),
+ { classId: "cls9", teacherEmail: "teacher@mvla.net", needsTeacherLookup: false },
+ "trims whitespace around join ref"
+);
+
+assert(parseJoinRef("only-id", org)?.needsTeacherLookup === true, "short ref flags lookup");
+assert(parseJoinRef("a@b.c/id", org)?.needsTeacherLookup === false, "prefixed ref skips lookup");
+
+if (failed) {
+ console.error(`\n${failed} failure(s)`);
+ process.exit(1);
+}
+console.log("\nall join-class tests passed");
diff --git a/src/common/paths.ts b/src/common/paths.ts
index d8c73798..f6764244 100644
--- a/src/common/paths.ts
+++ b/src/common/paths.ts
@@ -22,6 +22,22 @@ export interface ParsedClassPath {
hasTeacherPrefix: boolean;
}
+/**
+ * P4 flat enroll: join-ref parts for Add Class / Onboarding.
+ * Prefer bare classId for enrollClass; dual-read when teacher is unknown.
+ */
+export interface JoinRefParts {
+ /** Bare classId for enrollClass({ classId }) / store.add_class. */
+ classId: string;
+ /** Present when the join ref included a teacher email or local prefix. */
+ teacherEmail?: string;
+ /**
+ * True for short bare classId refs — caller must dual-read (class_from_ref)
+ * to obtain teacher email before listing classes.
+ */
+ needsTeacherLookup: boolean;
+}
+
export interface ParsedTaskPath {
teacherEmail?: string;
classId: string;
@@ -86,6 +102,34 @@ export function parseClassId(
return null;
}
+/**
+ * P4: Parse a join ref (route or code→ref) into teacher + bare classId.
+ * Does not touch Firestore; async teacher lookup stays in AddClassForm.
+ * Accepts: classId | email/classId | local~classId (slash or ~).
+ * No nested classes/{email}/classes writers — bare classId enroll only.
+ */
+export function parseJoinRef(
+ ref: string | undefined | null,
+ orgDomain: string
+): JoinRefParts | null {
+ if (!ref || typeof ref !== "string") return null;
+ const trimmed = ref.trim();
+ if (!trimmed) return null;
+
+ const parsed = parseClassId(trimmed, orgDomain);
+ if (!parsed?.classId) return null;
+
+ if (!parsed.hasTeacherPrefix) {
+ return { classId: parsed.classId, needsTeacherLookup: true };
+ }
+
+ return {
+ classId: parsed.classId,
+ teacherEmail: parsed.teacherEmail,
+ needsTeacherLookup: false,
+ };
+}
+
/**
* Parse task identity from slash id or ~ ref.
* Accepts: classId/taskId | email/classId/taskId | local~classId~taskId | classId~taskId
diff --git a/src/components/Portal/AddClassForm.vue b/src/components/Portal/AddClassForm.vue
new file mode 100644
index 00000000..e7ba0c73
--- /dev/null
+++ b/src/components/Portal/AddClassForm.vue
@@ -0,0 +1,355 @@
+
+ {{ !is_join ? "Add a" : "Join" }} Class