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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"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",
"test:drive-status": "node scripts/test-drive-status.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
40 changes: 40 additions & 0 deletions scripts/test-drive-status.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Unit tests for Drive account status parsing (mirrors workspace.ts helper).
* Run: node scripts/test-drive-status.mjs
*/
function parseDriveConnected(payload) {
if (!payload || typeof payload !== "object") return false;
const row = payload;
if (row.connected === true) return true;
if (row.drive_connected === true) return true;
const status = typeof row.status === "string" ? row.status.toLowerCase() : "";
return status === "connected" || status === "ok";
}

let failed = 0;

function assert(cond, msg) {
if (!cond) {
failed++;
console.error("FAIL:", msg);
} else {
console.log("ok:", msg);
}
}

console.log("--- drive status parse tests ---\n");

assert(parseDriveConnected({ connected: true }), "connected: true");
assert(parseDriveConnected({ drive_connected: true }), "drive_connected: true");
assert(parseDriveConnected({ status: "connected" }), 'status: "connected"');
assert(parseDriveConnected({ status: "ok" }), 'status: "ok"');
assert(!parseDriveConnected({ connected: false }), "connected: false");
assert(!parseDriveConnected({ status: "disconnected" }), 'status: "disconnected"');
assert(!parseDriveConnected(null), "null");
assert(!parseDriveConnected(undefined), "undefined");

if (failed) {
console.error(`\n${failed} test(s) failed`);
process.exit(1);
}
console.log("\nAll drive status tests passed.");
43 changes: 42 additions & 1 deletion src/common/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,51 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string):
}
}

/** sessionStorage key for post-OAuth return path (web platform). */
export const DRIVE_OAUTH_RETURN_KEY = "mv_drive_oauth_return";

/** Account-level Drive connection (not per-workspace `drive_connected`). */
export function parseDriveConnected(payload: unknown): boolean {
if (!payload || typeof payload !== "object") return false;
const row = payload as Record<string, unknown>;
if (row.connected === true) return true;
if (row.drive_connected === true) return true;
const status = typeof row.status === "string" ? row.status.toLowerCase() : "";
return status === "connected" || status === "ok";
}

/** GET /api/v1/me/drive/status — whether the signed-in account has Drive linked. */
export async function fetchDriveStatus(): Promise<boolean> {
try {
const payload = await apiFetch<unknown>("/api/v1/me/drive/status");
return parseDriveConnected(payload);
} catch (err) {
if (isMissingEndpoint(err)) return false;
throw err;
}
}

export function storeDriveOAuthReturnPath(path?: string): void {
const target = (path || `${window.location.pathname}${window.location.search}`).trim();
if (!target) return;
sessionStorage.setItem(DRIVE_OAUTH_RETURN_KEY, target);
}

export function consumeDriveOAuthReturnPath(): string | null {
const path = sessionStorage.getItem(DRIVE_OAUTH_RETURN_KEY);
sessionStorage.removeItem(DRIVE_OAUTH_RETURN_KEY);
return path?.trim() || null;
}

export function dispatchDriveStatusChanged(): void {
window.dispatchEvent(new CustomEvent("drive-status-changed"));
}

/** Start Google Drive OAuth — returns redirect URL. */
export async function startDriveOAuth(): Promise<string> {
export async function startDriveOAuth(platform: "web" | "brief" = "web"): Promise<string> {
const payload = await apiFetch<{ url?: string; state?: string }>("/api/v1/me/drive/oauth/start", {
method: "POST",
body: { platform },
});
const url = payload?.url?.trim();
if (!url) {
Expand Down
94 changes: 75 additions & 19 deletions src/components/Portal/TaskWorkspace.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@
<span class="styled_line__value workspace_panel">
<div v-if="loading" class="workspace_loading overlay_contents_text">Loading workspace…</div>

<template v-else-if="!driveConnected">
<p class="workspace_hint overlay_contents_text">
Connect Google Drive to attach a workspace and collect files for this {{ taskType }}.
{{ isTeacherMode ? "Shared among class teachers." : "Only you can see your workspace." }}
</p>
<div class="workspace_actions bottom_actions workspace_actions--compact">
<button
class="primary_styled workspace_action"
type="button"
:disabled="busy"
@click="connectDrive"
>
Connect Drive
</button>
</div>
</template>

<template v-else-if="!workspace">
<p v-if="!showLink" class="workspace_hint overlay_contents_text">
Attach a workspace to collect files for this {{ taskType }}.
Expand All @@ -13,10 +30,10 @@

<template v-if="!showLink">
<div class="workspace_actions bottom_actions workspace_actions--compact">
<button class="primary_styled workspace_action" :disabled="busy" @click="enableWorkspace">
<button class="primary_styled workspace_action" type="button" :disabled="busy" @click="enableWorkspace">
Enable
</button>
<button class="secondary_styled workspace_action" :disabled="busy" @click="openLinkMode">
<button class="secondary_styled workspace_action" type="button" :disabled="busy" @click="openLinkMode">
Link
</button>
</div>
Expand Down Expand Up @@ -71,16 +88,6 @@
<span v-else class="workspace_chip button_pointer_text" :title="workspace.id">
{{ workspace.id }}
</span>
<span
v-if="!driveConnected"
class="button_pointer_text workspace_drive_quiet"
role="button"
tabindex="0"
@click="connectDrive"
@keydown.enter.prevent="connectDrive"
>
Connect Drive
</span>
</div>

<div
Expand Down Expand Up @@ -173,12 +180,14 @@ import {
deleteWorkspaceFile,
destroyTaskWorkspace,
destroyTeacherWorkspace,
fetchDriveStatus,
fetchTaskWorkspace,
fetchTeacherWorkspace,
fetchWorkspace,
linkTaskWorkspace,
linkTeacherWorkspace,
startDriveOAuth,
storeDriveOAuthReturnPath,
uploadWorkspaceFile,
workspaceDriveFolderUrl,
} from "@/common/workspace";
Expand Down Expand Up @@ -228,6 +237,13 @@ export default {
},
mounted() {
this.loadWorkspace();
this._onDriveStatusChanged = () => this.refreshDriveStatus();
window.addEventListener("drive-status-changed", this._onDriveStatusChanged);
},
beforeUnmount() {
if (this._onDriveStatusChanged) {
window.removeEventListener("drive-status-changed", this._onDriveStatusChanged);
}
},
methods: {
openLinkMode() {
Expand Down Expand Up @@ -288,6 +304,7 @@ export default {
async loadWorkspace() {
this.loading = true;
try {
const driveStatusPromise = fetchDriveStatus();
let ws = null;
if (this.initialWorkspaceId) {
ws = await fetchWorkspace(this.initialWorkspaceId);
Expand All @@ -297,13 +314,30 @@ export default {
ws = await fetchTaskWorkspace(this.taskPath);
}
this.workspace = ws;
this.driveConnected = ws?.drive_connected === true;
this.driveConnected = await driveStatusPromise;
} catch (err) {
this.$status?.warn?.("Workspace load failed", err);
} finally {
this.loading = false;
}
},
async refreshDriveStatus() {
try {
this.driveConnected = await fetchDriveStatus();
if (!this.driveConnected) return;
let ws = null;
if (this.initialWorkspaceId) {
ws = await fetchWorkspace(this.initialWorkspaceId);
} else if (this.isTeacherMode) {
ws = await fetchTeacherWorkspace(this.classId);
} else {
ws = await fetchTaskWorkspace(this.taskPath);
}
this.workspace = ws;
} catch (err) {
this.$status?.warn?.("Drive status refresh failed", err);
}
},
async enableWorkspace() {
this.busy = true;
try {
Expand Down Expand Up @@ -355,8 +389,9 @@ export default {
},
async connectDrive() {
try {
const url = await startDriveOAuth();
window.open(url, "_blank", "noopener");
storeDriveOAuthReturnPath(this.$route.fullPath);
const url = await startDriveOAuth("web");
window.location.assign(url);
} catch (err) {
new ErrorToast("Couldn't start Drive connect", err, 2000);
}
Expand Down Expand Up @@ -452,10 +487,16 @@ export default {
min-width: 0;
margin-left: 0;
height: var(--height-overlay-secondary-input);
min-height: var(--height-overlay-secondary-input);
padding: 0 var(--padding-overlay-secondary-input);
border-radius: 0;
font-size: 13px;
line-height: 1.2;
white-space: nowrap;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
}
.workspace_actions--compact.bottom_actions .workspace_action:first-child {
border-top-left-radius: var(--radius-overlay-input);
Expand All @@ -465,6 +506,18 @@ export default {
border-top-right-radius: var(--radius-overlay-input);
border-bottom-right-radius: var(--radius-overlay-input);
}
.workspace_footer.bottom_actions .secondary_styled.workspace_destroy {
height: var(--height-overlay-secondary-input);
min-height: var(--height-overlay-secondary-input);
padding: 0 var(--padding-overlay-secondary-input);
font-size: 13px;
line-height: 1.2;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0;
}
.workspace_link_select {
margin: 0;
}
Expand Down Expand Up @@ -509,7 +562,10 @@ a.workspace_chip:hover {
font-size: 14px;
font-family: inherit;
flex-shrink: 0;
line-height: 1;
line-height: 1.2;
display: inline-flex;
align-items: center;
justify-content: center;
}
.workspace_panel .workspace_add_file {
background-color: var(--color-overlay-action);
Expand Down Expand Up @@ -573,7 +629,7 @@ a.workspace_chip:hover {
min-width: 0;
padding: 6px 8px 6px 10px;
border-radius: var(--radius-overlay-input);
background: var(--color-overlay-input);
background-color: var(--color-overlay-secondary-input);
color: var(--color-on-overlay-input);
}
.workspace_file__icon {
Expand All @@ -584,7 +640,7 @@ a.workspace_chip:hover {
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: var(--color-overlay-secondary-input);
background-color: var(--color-overlay-secondary-action);
color: var(--color-on-overlay-input-alt);
font-size: 8px;
font-weight: 700;
Expand Down Expand Up @@ -641,7 +697,7 @@ span.workspace_file__name {
height: 20px;
border: none;
border-radius: 3px;
background-color: var(--color-overlay-input);
background-color: var(--color-overlay-secondary-action);
cursor: pointer;
padding: 0;
display: inline-flex;
Expand Down
31 changes: 30 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,34 @@ $(document.body).on("click", ".gohome", function () {
});

// page change
import { removePopup } from "@svonk/util";
import { removePopup, SuccessToast, ErrorToast } from "@svonk/util";
import { consumeDriveOAuthReturnPath, dispatchDriveStatusChanged } from "@/common/workspace";

function handleDriveOAuthReturn(to: { query: Record<string, unknown>; path: string; hash?: string }) {
const drive = typeof to.query.drive === "string" ? to.query.drive : "";
if (!drive) return;

const driveError = typeof to.query.drive_error === "string" ? to.query.drive_error : "";
const returnPath = consumeDriveOAuthReturnPath();

if (drive === "connected") {
new SuccessToast("Google Drive connected", 2000);
dispatchDriveStatusChanged();
} else if (drive === "error") {
new ErrorToast("Couldn't connect Google Drive", driveError || undefined, 2500);
}

const nextQuery = { ...to.query } as Record<string, string | string[] | null | undefined>;
delete nextQuery.drive;
delete nextQuery.drive_error;

if (returnPath) {
router.replace(returnPath);
} else {
router.replace({ path: to.path, query: nextQuery, hash: to.hash || undefined });
}
}

router.afterEach((to: any) => {
if (to.meta && to.meta.page_title) {
document.title = `${process.env.VUE_APP_BRAND_NAME_LONG} | ` + to.meta.page_title;
Expand All @@ -154,6 +181,8 @@ router.afterEach((to: any) => {
style.setProperty("--theme-color-text-dark", text_dark);
style.setProperty("--theme-color-hover", text_light + "33");
style.setProperty("--theme-color-hover-dark", text_dark + "33");

handleDriveOAuthReturn(to);
});

// router guard — requiresTeacher: role teacher|admin; requiresAdmin: role admin (via store getters)
Expand Down
Loading