diff --git a/.claude/rules/scratch-gui/e2e-test.md b/.claude/rules/scratch-gui/e2e-test.md index a3f0bf93b88..a79e3cb1bdd 100644 --- a/.claude/rules/scratch-gui/e2e-test.md +++ b/.claude/rules/scratch-gui/e2e-test.md @@ -108,9 +108,21 @@ expect(await isActiveByTestId(driver, 'ruby-toolbar-mode-dncl')).toBe(true); | data-testid | 要素 | 値の内容 | |------------|------|----------| -| `classroom-menu-label` | span | メニューバーのクラス表示テキスト | -| `classroom-menu-class-name` | span | 課題名(またはクラス名) | -| `classroom-menu-seat-number` | span | 出席番号(0埋め2桁) | +| `classroom-menu-label` | span | メニューバーのクラス表示テキスト全体(参加中は「クラス:出席番号NN」、未参加は「クラス」) | +| `classroom-menu-seat-number` | span | 出席番号(0埋め2桁、参加中のみレンダリング) | +| `classroom-kicked-banner` | div | 先生に kick されたときに seat 画面で表示される警告バナー (#692) | +| `classroom-kicked-banner-dismiss` | button | バナーの × | +| `kick-request-confirm-dialog` | div | 「使用中の席」をタップすると現れる退室依頼ダイアログ (#692) | +| `kick-request-reason-input` | textarea | ひと言入力欄 (任意、最大 200 字) | +| `kick-request-submit` | button | 依頼を送る | +| `kick-request-cancel` | button | キャンセル | +| `kick-request-error` | div | 依頼送信エラー表示 | +| `kick-request-pending-banner` | div | 依頼後に表示される「先生に依頼中です…」バナー | +| `classroom-seat-kick-request-{N}` | span | 先生クラス詳細の座席に表示される赤いバッジ | +| `classroom-member-kick-request-panel` | div | メンバー詳細パネル内の依頼セクション | +| `classroom-kick-request-row-{requestId}` | div | 1 依頼の行 | +| `classroom-kick-request-approve-{requestId}` | button | 承認 = kick + リクエスト削除 | +| `classroom-kick-request-reject-{requestId}` | button | 却下 = リクエストのみ削除 | | `classroom-list` | ul | クラス一覧 | | `classroom-item-{id}` | li | クラス一覧の各項目 | | `classroom-item-name-{id}` | span | クラス名 | diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 68aa5e9f575..96c8097eee5 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -63,6 +63,7 @@ upstream (Scratch) ファイルは対象外。 **個別ファイル:** - `src/containers/block-display-modal.jsx` +- `src/containers/classroom-classcode-utils.js` - `src/containers/classroom-error-utils.js` - `src/containers/classroom-modal.jsx` - `src/containers/classroom-teacher-modal.jsx` @@ -99,6 +100,7 @@ upstream (Scratch) ファイルは対象外。 - `src/lib/auto-correct.js` - `src/lib/backpack-mesh-v1-migration.js` - `src/lib/classroom-api.js` +- `src/lib/classroom-kick-request-storage.js` - `src/lib/deck-setup.js` - `src/lib/google-classroom-auth.js` - `src/lib/block-utils.js` @@ -217,6 +219,8 @@ upstream (Scratch) ファイルは対象外。 - `test/unit/components/student-join-form.test.js` - `test/unit/containers/backpack.test.jsx` - `test/unit/containers/cards.test.jsx` +- `test/unit/containers/classroom-classcode-utils.test.js` +- `test/unit/containers/classroom-error-utils.test.js` - `test/unit/containers/connection-modal-connected-message.test.jsx` - `test/unit/containers/connection-modal-smalrubot-s1.test.jsx` - `test/unit/containers/connection-modal.test.jsx` @@ -233,6 +237,8 @@ upstream (Scratch) ファイルは対象外。 - `test/unit/lib/block-display-initialization.test.js` - `test/unit/lib/blockly-private-api.test.js` - `test/unit/lib/blocks-gesture-recovery.test.js` +- `test/unit/lib/classroom-api.test.js` +- `test/unit/lib/classroom-kick-request-storage.test.js` - `test/unit/lib/deck-setup.test.js` - `test/unit/lib/blocks-screenshot.test.js` - `test/unit/lib/calculate-popup-position.test.js` diff --git a/docs/classroom/README.md b/docs/classroom/README.md index 94c7722e62d..943e48581d9 100644 --- a/docs/classroom/README.md +++ b/docs/classroom/README.md @@ -38,6 +38,13 @@ Smalruby Classroom は、日本の学校の授業で Smalruby を使うための 3. 「生徒」→ 参加コード入力 → 席番号を選択 4. プロジェクトを作成し「提出」ボタンで提出 +## 強制退室通知と退室リクエスト (Issue #692) + +- 先生がメンバーを Remove すると、対象の生徒は次にクラスモーダルを開いたタイミングで「先生によってクラスから退室させられました」と通知され、自動で出席番号選択画面に戻ります。同じクラスの席を即座に選び直して再参加できます。 +- 生徒は seat 選択画面で「使用中」の席をタップして、先生に退室を依頼できます。先生はクラス詳細の座席バッジ + メンバー詳細パネルで「承認 (kick)」「却下」を選べます。 +- 生徒のクラス参加状態は localStorage に永続化されており、ブラウザ再起動・新タブ・classcode URL の再オープンでも保持されます (旧仕様: sessionStorage)。 +- 異なる classcode URL を開くと、古い席はサーバー側でも自動で leave されます。 + ## Mesh v2 ドメインの自動連動 クラスに参加中(生徒)または先生がクラス管理でクラスを選択中の間、Mesh v2 のドメインは**そのクラスの参加コードに自動で固定**される。これにより同じクラスの先生・生徒が自動で同じ Mesh ドメインに集まる。詳細は [`docs/mesh-v2/README.md`](../mesh-v2/README.md#クラス管理との連動) を参照。 diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md index 1902d979a2d..71f4e3c3ef7 100644 --- a/docs/classroom/architecture.md +++ b/docs/classroom/architecture.md @@ -164,8 +164,11 @@ sequenceDiagram | `GET` | `/classrooms/{id}` | クラス詳細 | | `PATCH` | `/classrooms/{id}` | クラス更新 | | `DELETE` | `/classrooms/{id}` | クラス削除 (アーカイブ) | -| `GET` | `/classrooms/{id}/members` | メンバー一覧 | -| `DELETE` | `/classrooms/{id}/members/{memberId}` | メンバー削除 | +| `GET` | `/classrooms/{id}/members` | メンバー一覧 (kicked 行は除外) | +| `DELETE` | `/classrooms/{id}/members/{memberId}` | メンバー削除 = ソフト kick (1h tombstone を残し、`kicked: true` をマーク)。verify-session が 410 reason='kicked' を返せるようにするための仕様。lookup の takenSeats も即座に空く | +| `GET` | `/classrooms/{id}/kick-requests` | 退室リクエスト一覧 (Issue #692) | +| `POST` | `/classrooms/{id}/kick-requests/{requestId}/approve` | 承認 = `handleDeleteMember` (= kick) + 同席への全リクエスト削除 | +| `DELETE` | `/classrooms/{id}/kick-requests/{requestId}` | 却下 = リクエストのみ削除、メンバーは残る | | `GET` | `/classrooms/{id}/submissions` | 提出一覧 (ダウンロード URL 付き) | | `PATCH` | `/classrooms/{id}/submissions/{subId}` | 提出の返却・コメント | @@ -174,8 +177,9 @@ sequenceDiagram | Method | Path | 認証 | 説明 | |--------|------|------|------| | `POST` | `/classrooms/lookup` | 不要 | 参加コードでクラス検索 | +| `POST` | `/classrooms/lookup/kick-request` | 不要 | 「使用中の席」を空けてもらう依頼を先生に送信 (Issue #692) | | `POST` | `/classrooms/join` | 不要 | クラスに参加 (→ sessionToken 取得) | -| `POST` | `/classrooms/verify-session` | Session Token | セッション検証 + 提出状況取得 | +| `POST` | `/classrooms/verify-session` | Session Token | セッション検証 + 提出状況取得。kick された生徒には 410 + `{reason: 'kicked', joinCode, className, seatNumber}` を返す | | `POST` | `/classrooms/{id}/submissions` | Session Token | 提出 (Presigned URL 取得) | | `DELETE` | `/classrooms/{id}/members/me` | Session Token | 自主退出 | @@ -230,6 +234,34 @@ erDiagram **GSI:** - `sessionToken-index` — セッショントークンでの認証 +### ClassroomKickRequests テーブル (Issue #692) + +```mermaid +erDiagram + ClassroomKickRequests { + string classroomId PK "UUID" + string requestId SK "UUID" + number seatNumber "1-50" + string reason "任意・最大200文字" + string sourceIpHash "abuse trace用 (sha256 16桁)" + string createdAt "ISO8601" + number ttl "Unix timestamp (1h)" + } +``` + +**GSI:** +- `classroomId-seatNumber-index` — 承認時に同席への全リクエストを batch-delete するため + +短命 (TTL 1 時間) のレコード。生徒が「使用中の席を空けてください」と先生に依頼するときに作成される。同一席への複数依頼を許容する仕様 (規制なし)。 + +### Memberships の kick tombstone (Issue #692) + +`handleDeleteMember` (= 教師 kick) は **行を物理削除しない**: +- `kicked: true, kickedAt, kickJoinCode, kickClassName, kickSeatNumber, ttl: now+1h` をセット +- これにより、kick された生徒の次回 `verify-session` 呼出で 410 reason='kicked' を返せる +- `listMembers` / `lookup takenSeats` は `FilterExpression: attribute_not_exists(kicked) OR kicked <> :true` で tombstone を除外 +- `joinClassroom` の `ConditionExpression: attribute_not_exists(memberId) OR kicked = :true` で新生徒が kicked 行を上書き可能 (tombstone はその時点で消滅) + ### ClassroomSubmissions テーブル ```mermaid diff --git a/docs/classroom/screenshots/0103-menu-bar-joined.png b/docs/classroom/screenshots/0103-menu-bar-joined.png new file mode 100644 index 00000000000..d4224b033d5 Binary files /dev/null and b/docs/classroom/screenshots/0103-menu-bar-joined.png differ diff --git a/docs/classroom/screenshots/0208-teacher-kick-request-panel.png b/docs/classroom/screenshots/0208-teacher-kick-request-panel.png new file mode 100644 index 00000000000..64f9500ad1a Binary files /dev/null and b/docs/classroom/screenshots/0208-teacher-kick-request-panel.png differ diff --git a/docs/classroom/screenshots/0307-student-kicked-banner.png b/docs/classroom/screenshots/0307-student-kicked-banner.png new file mode 100644 index 00000000000..8d8feb27519 Binary files /dev/null and b/docs/classroom/screenshots/0307-student-kicked-banner.png differ diff --git a/docs/classroom/screenshots/0308-student-kick-request-pending.png b/docs/classroom/screenshots/0308-student-kick-request-pending.png new file mode 100644 index 00000000000..becc0b637cc Binary files /dev/null and b/docs/classroom/screenshots/0308-student-kick-request-pending.png differ diff --git a/docs/classroom/screenshots/0309-student-kick-request-rejected.png b/docs/classroom/screenshots/0309-student-kick-request-rejected.png new file mode 100644 index 00000000000..c1237df8387 Binary files /dev/null and b/docs/classroom/screenshots/0309-student-kick-request-rejected.png differ diff --git a/docs/classroom/source-code.md b/docs/classroom/source-code.md index 2102276ec8d..efb28f145b8 100644 --- a/docs/classroom/source-code.md +++ b/docs/classroom/source-code.md @@ -20,7 +20,9 @@ smalruby3-editor/ │ │ │ │ ├── classroom-modal.css │ │ │ │ ├── class-code-display.jsx ← 参加コード表示(全画面対応) │ │ │ │ ├── google-course-list.jsx ← GC コースタイルグリッド -│ │ │ │ ├── teacher-class-detail.jsx ← 先生クラス詳細 +│ │ │ │ ├── kick-request-confirm-dialog.jsx ← 退室依頼ダイアログ (#692) +│ │ │ │ ├── teacher-class-detail.jsx ← 先生クラス詳細 (退室リクエストバッジ + 退室通知バナー) +│ │ │ │ ├── teacher-member-detail.jsx ← メンバー詳細パネル (承認/却下ボタン) │ │ │ │ ├── teacher-create-form.jsx ← クラス作成フォーム │ │ │ │ └── teacher-post-assignment.jsx ← 課題配信 │ │ │ ├── classroom-teacher-modal/ ← 先生用フルスクリーンモーダル @@ -43,7 +45,8 @@ smalruby3-editor/ │ │ │ ├── classroom-error-utils.js ← エラーメッセージ変換 │ │ │ └── alert.jsx ← Alert コンテナ(参加しなおす対応) │ │ ├── lib/ -│ │ │ ├── classroom-api.js ← API クライアント (20メソッド, リトライ付き) +│ │ │ ├── classroom-api.js ← API クライアント (24メソッド, リトライ付き) +│ │ │ ├── classroom-kick-request-storage.js ← 退室依頼の localStorage 永続化 (#692) │ │ │ ├── google-classroom-auth.js ← Google Classroom OAuth │ │ │ └── alerts/index.jsx ← Alert 定義(classroomSessionExpired 追加) │ │ ├── reducers/ diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md index 497baa7bca9..583699dae84 100644 --- a/docs/classroom/testing.md +++ b/docs/classroom/testing.md @@ -163,9 +163,28 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | data-testid | 要素 | 説明 | |------------|------|------| | `classroom-menu-button` | div | クラスボタン(コンテナ) | -| `classroom-menu-label` | span | メニューバーのクラス表示テキスト | -| `classroom-menu-class-name` | span | 課題名(またはクラス名) | -| `classroom-menu-seat-number` | span | 出席番号(0埋め2桁) | +| `classroom-menu-label` | span | メニューバーのクラス表示テキスト全体(参加中は「クラス:出席番号NN」、未参加時は「クラス」)| +| `classroom-menu-seat-number` | span | 出席番号(0埋め2桁、参加中のみレンダリングされる) | + +### 強制退室通知 / 退室リクエスト (Issue #692) + +| data-testid | 要素 | 説明 | +|------------|------|------| +| `classroom-kicked-banner` | div | 生徒の seat 画面に表示する「先生によって退室させられました」バナー | +| `classroom-kicked-banner-dismiss` | button | バナーの × | +| `kick-request-confirm-dialog` | div | 「使用中の席」をタップしたとき表示される退室依頼ダイアログ | +| `kick-request-reason-input` | textarea | 任意のひと言入力欄(200 字制限)| +| `kick-request-submit` | button | 依頼を送信 | +| `kick-request-cancel` | button | ダイアログを閉じる | +| `kick-request-error` | div | 依頼送信エラー表示 | +| `kick-request-pending-banner` | div | 「先生に依頼中です…」バナー(5 秒ごとに lookupClassroom を polling)| +| `kick-request-rejected-banner` | div | 「依頼は受理されませんでした」バナー (却下 / TTL 期限切れ検出時に pending と差し替え) | +| `kick-request-rejected-banner-dismiss` | button | × ボタン | +| `classroom-seat-kick-request-{seatNumber}` | span | 先生クラス詳細の座席グリッドに表示する赤いバッジ「!」| +| `classroom-member-kick-request-panel` | div | 先生メンバー詳細パネルに表示される依頼一覧 | +| `classroom-kick-request-row-{requestId}` | div | 1 リクエストの行 | +| `classroom-kick-request-approve-{requestId}` | button | 承認(kick + リクエスト削除)| +| `classroom-kick-request-reject-{requestId}` | button | 却下(リクエストのみ削除)| ### 汎用 diff --git a/docs/classroom/ui-ux.md b/docs/classroom/ui-ux.md index fa0c3f8df1a..bc7f4ebc74b 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -62,12 +62,14 @@ stateDiagram-v2 | クラスボタン | 「クラス」(未参加時) | `classroom-menu-button` | | ラベル | — | `classroom-menu-label` | -生徒がクラスに参加中の場合、ボタンのテキストが課題名と出席番号に変わります: +生徒がクラスに参加中の場合、ボタンのテキストは固定の「クラス:出席番号NN」表記に変わります (NN は 0 埋め 2 桁): | 要素 | テキスト/内容 | data-testid | |------|-------------|-------------| -| 課題名(またはクラス名) | 例: 「第1回チャットアプリを作ろう」 | `classroom-menu-class-name` | -| 出席番号 | 例: 「/ 03」(0埋め2桁) | `classroom-menu-seat-number` | +| ラベル全体 | 例: 「クラス:出席番号03」 | `classroom-menu-label` | +| 出席番号 (内側 span) | 例: 「03」 | `classroom-menu-seat-number` | + +未参加時はラベルが「クラス」のみになる。 課題名 / クラス名はメニューに表示せず、モーダル内でのみ確認する設計。 --- @@ -219,10 +221,12 @@ Google Classroom からインポートした場合は「インポート元: {コ | セルの色 | 状態 | テキスト | |---------|------|---------| -| グレー (`#e0e0e0`) | 空席 | 出席番号のみ (例: 「5」) | -| 青 (`#4285f4`) | 着席(未提出) | 出席番号(下線付き) | -| 緑 (`#34a853`) | 提出済み | 「✓」+ 出席番号 (例: 「✓5」) | -| オレンジ (`#ff9800`) | 返却済み | 出席番号 | +| 青 (`#4c97ff`) | 空席 | 出席番号のみ (例: 「5」) | +| グレー (`#d9d9d9`) | 着席(未提出) | 出席番号(下線付き) | +| 緑 (`#0fbd8c`) | 提出済み | 「✓」+ 出席番号 (例: 「✓5」) | +| オレンジ (`#ff8c1a`) | 返却済み | 出席番号 | + +色は生徒側の出席番号選択画面と統一されている: 青 = 「空き / 選択可能 (生徒視点では選べる席、先生視点では未参加)」、灰色 = 「使用中」。 セルをクリックすると右カラムに詳細パネルが表示されます。 diff --git a/docs/classroom/user-stories.md b/docs/classroom/user-stories.md index 75b68c584b3..8d4610a019c 100644 --- a/docs/classroom/user-stories.md +++ b/docs/classroom/user-stories.md @@ -124,6 +124,14 @@ sequenceDiagram 授業が終わったクラスは削除(アーカイブ)できます。生徒のセッションは無効化されます。 +### 6. 退室リクエストに対応する (Issue #692) + +生徒が「使用中の席を空けてほしい」と依頼を送ると、先生のクラス詳細画面では: +- 該当席に赤い「!」バッジが表示される +- その席をクリックすると、メンバー詳細パネルに依頼一覧(生徒からのひと言付き)と「承認(この生徒を退室させる)」「却下」の 2 ボタンが現れる + +「承認」を押すと、その席の生徒が退室させられ (5. と同じ kick 処理) + すべての同席依頼が一括で消える。「却下」を押すと、対象の依頼だけが消え、座っている生徒はそのまま。 + --- ## 生徒ロール @@ -201,6 +209,16 @@ URL に `classcode` パラメータがあると、モーダルが自動で開き 生徒はいつでもクラスから退出できます。退出するとセッションが無効化され、提出データは保持されます。 +### 5. 間違った席の人に退室を依頼する (Issue #692) + +クラスに参加しようとしたら自分の出席番号がすでに他の生徒に取られていた場合、生徒は「使用中」になっている席をタップして先生に退室を依頼できます。任意で先生へひと言(最大 200 文字)も添えられます。 + +依頼を送ると seat 選択画面に「先生に退室を依頼中です…」バナーが表示され、5 秒ごとに席が空いたかチェックされます。先生が承認すると席が空き、生徒は再びその席を選べるようになります。先生が却下した場合は、依頼は消えますが席は埋まったままです (TTL 1h で自動消滅)。 + +### 6. 先生に退室させられたときの挙動 (Issue #692) + +先生がクラス管理画面で生徒を Remove したり、生徒が送った退室リクエストを承認したりすると、対象の生徒は次にクラスモーダルを開いたタイミングで「先生によってクラスから退室させられました」とバナーで通知され、自動的に出席番号選択画面に戻ります。元と同じクラスがプリロードされているので、ワンクリックで席を選び直して再参加できます。 + --- ## 典型的な授業の流れ diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index 214ae853961..56f257307e1 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -20,6 +20,7 @@ import * as crypto from 'crypto'; const CLASSROOMS_TABLE = process.env.CLASSROOMS_TABLE_NAME || 'Classrooms'; const MEMBERSHIPS_TABLE = process.env.MEMBERSHIPS_TABLE_NAME || 'ClassroomMemberships'; const SUBMISSIONS_TABLE = process.env.SUBMISSIONS_TABLE_NAME || 'ClassroomSubmissions'; +const KICK_REQUESTS_TABLE = process.env.KICK_REQUESTS_TABLE_NAME || 'ClassroomKickRequests'; const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-classroom-submissions'; const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || ''; @@ -56,6 +57,10 @@ const PRESIGNED_URL_DOWNLOAD_EXPIRY = parseInt(process.env.PRESIGNED_URL_DOWNLOA const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB const MAX_SCREENSHOT_COUNT = 20; const MAX_TEACHER_COMMENT_LENGTH = 500; +// Kick request TTL: short-lived (1h) since the student is actively waiting +// for the teacher to act. Expired requests are removed by DynamoDB TTL. +const KICK_REQUEST_TTL_SECONDS = parseInt(process.env.KICK_REQUEST_TTL_SECONDS || '3600', 10); +const MAX_KICK_REQUEST_REASON_LENGTH = 200; // --- DynamoDB Client --- @@ -185,6 +190,29 @@ class GoogleAPIError extends Error { } } +// Tombstone error: the member's row exists but is flagged kicked. Surface this +// to the student so the UI can show a specific "you were removed by the +// teacher" banner instead of the generic "session expired" alert. +class KickedError extends Error { + joinCode: string; + className: string; + seatNumber: number; + constructor(joinCode: string, className: string, seatNumber: number) { + super('You were removed from the classroom by the teacher'); + this.name = 'KickedError'; + this.joinCode = joinCode; + this.className = className; + this.seatNumber = seatNumber; + } +} + +// Kick tombstone TTL: how long after a teacher kick we keep the row around so +// the kicked student's next verify-session can read the reason. Anything beyond +// this (1 hour) and we don't bother — the student will hit the regular "session +// expired" path. The tombstone is also consumed proactively when another +// student joins the seat. +const KICK_TOMBSTONE_TTL_SECONDS = parseInt(process.env.KICK_TOMBSTONE_TTL_SECONDS || '3600', 10); + // --- Google Classroom API proxy --- async function callGoogleClassroomAPI( @@ -557,7 +585,12 @@ async function handleJoinClassroom(sourceIp: string, body: Record :true', + ExpressionAttributeValues: { ':cid': classroomId, ':true': true }, })), docClient.send(new QueryCommand({ TableName: SUBMISSIONS_TABLE, @@ -717,11 +754,13 @@ async function handleLookupClassroom(sourceIp: string, body: Record :true', + ExpressionAttributeValues: { ':cid': classroom.classroomId, ':true': true }, ProjectionExpression: 'memberId', })); @@ -730,6 +769,20 @@ async function handleLookupClassroom(sourceIp: string, body: Record n > 0); + // Active kick request IDs for the classroom. The student polls this list + // alongside takenSeats so it can tell "the teacher rejected my request / + // the TTL ran out" (my requestId is no longer in the list AND my target + // seat is still occupied) from "the teacher approved" (my target seat is + // now free). Without this round-trip, a rejected student would just watch + // the pending banner for up to an hour. + const kickRequestResult = await docClient.send(new QueryCommand({ + TableName: KICK_REQUESTS_TABLE, + KeyConditionExpression: 'classroomId = :cid', + ExpressionAttributeValues: { ':cid': classroom.classroomId }, + ProjectionExpression: 'requestId', + })); + const activeKickRequestIds = (kickRequestResult.Items || []).map(item => item.requestId as string); + return { statusCode: 200, body: JSON.stringify({ @@ -738,6 +791,7 @@ async function handleLookupClassroom(sourceIp: string, body: Record MAX_KICK_REQUEST_REASON_LENGTH) { + throw new ValidationError(`Kick request reason must be ${MAX_KICK_REQUEST_REASON_LENGTH} characters or less`); + } + return trimmed || undefined; +} + +// Lookup classroom by joinCode + ensure a non-kicked occupant exists at the +// given seat. Used by handleCreateKickRequest to refuse requests for seats +// that are already empty (so the teacher doesn't see noise from misclicks +// or stale UI). Returns the classroom row. +async function findClassroomWithSeatOccupied( + joinCode: string, + seatNumber: number, +): Promise> { + const classroomResult = await docClient.send(new QueryCommand({ + TableName: CLASSROOMS_TABLE, + IndexName: 'joinCode-index', + KeyConditionExpression: 'joinCode = :jc', + ExpressionAttributeValues: { ':jc': joinCode }, + Limit: 1, + })); + if (!classroomResult.Items || classroomResult.Items.length === 0) { + throw new NotFoundError('Invalid join code'); + } + const classroom = classroomResult.Items[0]; + if (classroom.status !== 'active') { + throw new NotFoundError('This classroom is no longer active'); + } + if (seatNumber < 1 || seatNumber > (classroom.studentCount as number)) { + throw new ValidationError(`Seat number must be between 1 and ${classroom.studentCount}`); + } + const memberId = `seat-${String(seatNumber).padStart(2, '0')}`; + const memberResult = await docClient.send(new GetCommand({ TableName: MEMBERSHIPS_TABLE, - Key: { classroomId, memberId }, + Key: { classroomId: classroom.classroomId, memberId }, + })); + if (!memberResult.Item || memberResult.Item.kicked === true) { + // Seat already empty (or only holds a kick tombstone) — nothing to free up. + throw new NotFoundError(`Seat ${seatNumber} is not currently occupied`); + } + return classroom; +} + +async function handleCreateKickRequest( + sourceIp: string, + body: Record, +): Promise { + // Reuse the join endpoint's IP-based rate limit — same threat model + // (anonymous endpoint, abuse risk if open). The body fields are validated + // independently below so we surface friendlier errors than the limit. + checkJoinRateLimit(sourceIp); + const joinCode = validateJoinCode(body.joinCode); + const seatRaw = body.seatNumber; + const seatNumber = + typeof seatRaw === 'number' ? seatRaw : parseInt(String(seatRaw), 10); + if (isNaN(seatNumber)) { + throw new ValidationError('Seat number is required'); + } + const reason = validateKickRequestReason(body.reason); + const classroom = await findClassroomWithSeatOccupied(joinCode, seatNumber); + if (seatNumber < 1 || seatNumber > (classroom.studentCount as number)) { + throw new ValidationError(`Seat number must be between 1 and ${classroom.studentCount}`); + } + + const requestId = crypto.randomUUID(); + const now = new Date().toISOString(); + await docClient.send(new PutCommand({ + TableName: KICK_REQUESTS_TABLE, + Item: { + classroomId: classroom.classroomId, + requestId, + seatNumber, + reason: reason || null, + sourceIpHash: crypto.createHash('sha256').update(sourceIp).digest('hex').slice(0, 16), + createdAt: now, + ttl: Math.floor(Date.now() / 1000) + KICK_REQUEST_TTL_SECONDS, + }, + })); + + return { + statusCode: 201, + body: JSON.stringify({ + requestId, + classroomId: classroom.classroomId, + seatNumber, + }), + }; +} + +async function handleListKickRequests( + teacherSub: string, + classroomId: string, +): Promise { + // Verify ownership: only the owning teacher may list requests. + const classroom = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, })); + if (!classroom.Item || classroom.Item.teacherSub !== teacherSub) { + throw new AuthError('Not authorized to view kick requests for this classroom'); + } + + const result = await docClient.send(new QueryCommand({ + TableName: KICK_REQUESTS_TABLE, + KeyConditionExpression: 'classroomId = :cid', + ExpressionAttributeValues: { ':cid': classroomId }, + })); + const requests = (result.Items || []).map(item => ({ + requestId: item.requestId, + seatNumber: item.seatNumber, + reason: item.reason || null, + createdAt: item.createdAt, + })); + + return { + statusCode: 200, + body: JSON.stringify({ requests }), + }; +} + +async function handleApproveKickRequest( + teacherSub: string, + classroomId: string, + requestId: string, +): Promise { + // Verify ownership and read the request to learn which seat to kick. + const classroom = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + if (!classroom.Item || classroom.Item.teacherSub !== teacherSub) { + throw new AuthError('Not authorized to modify kick requests for this classroom'); + } + const reqResult = await docClient.send(new GetCommand({ + TableName: KICK_REQUESTS_TABLE, + Key: { classroomId, requestId }, + })); + if (!reqResult.Item) { + // Request already gone (TTL or someone else acted on it). Treat as success. + return { statusCode: 204, body: '' }; + } + const seatNumber = reqResult.Item.seatNumber as number; + const memberId = `seat-${String(seatNumber).padStart(2, '0')}`; + + // Reuse the existing kick logic so kicked students get the same + // 410 reason='kicked' from verify-session that a direct DELETE + // /members/{memberId} would produce. + await handleDeleteMember(teacherSub, classroomId, memberId); + + // Delete all requests targeting this seat (including the approved one + // and any duplicates the same seat may have accumulated). Otherwise + // the teacher would see ghost rows asking to kick a seat that is now + // empty. + const siblings = await docClient.send(new QueryCommand({ + TableName: KICK_REQUESTS_TABLE, + IndexName: 'classroomId-seatNumber-index', + KeyConditionExpression: 'classroomId = :cid AND seatNumber = :sn', + ExpressionAttributeValues: { ':cid': classroomId, ':sn': seatNumber }, + ProjectionExpression: 'requestId', + })); + if (siblings.Items && siblings.Items.length > 0) { + for (let i = 0; i < siblings.Items.length; i += 25) { + const batch = siblings.Items.slice(i, i + 25); + await docClient.send(new BatchWriteCommand({ + RequestItems: { + [KICK_REQUESTS_TABLE]: batch.map(item => ({ + DeleteRequest: { Key: { classroomId, requestId: item.requestId as string } }, + })), + }, + })); + } + } return { statusCode: 204, body: '' }; } +async function handleRejectKickRequest( + teacherSub: string, + classroomId: string, + requestId: string, +): Promise { + const classroom = await docClient.send(new GetCommand({ + TableName: CLASSROOMS_TABLE, + Key: { classroomId }, + })); + if (!classroom.Item || classroom.Item.teacherSub !== teacherSub) { + throw new AuthError('Not authorized to modify kick requests for this classroom'); + } + await docClient.send(new DeleteCommand({ + TableName: KICK_REQUESTS_TABLE, + Key: { classroomId, requestId }, + })); + return { statusCode: 204, body: '' }; +} + // --- Session Token Auth --- export async function verifySessionToken(sessionToken: string): Promise<{ classroomId: string; memberId: string }> { @@ -777,6 +1068,16 @@ export async function verifySessionToken(sessionToken: string): Promise<{ classr } const item = result.Items[0]; + // Surface kick tombstones as a distinct error so callers can return 410 + // with the reason. Non-verify-session callers (e.g. submission endpoints) + // also benefit: a kicked student shouldn't be able to submit any more. + if (item.kicked === true) { + throw new KickedError( + (item.kickJoinCode as string) || '', + (item.kickClassName as string) || '', + (item.kickSeatNumber as number) || 0, + ); + } return { classroomId: item.classroomId as string, memberId: item.memberId as string }; } @@ -1261,7 +1562,11 @@ async function handlePostAssignment( } async function handleVerifySession(sessionToken: string): Promise { - // verifySessionToken will throw AuthError if invalid + // verifySessionToken throws AuthError for unknown/expired tokens and + // KickedError when the row exists but the teacher removed the student. + // KickedError is caught in the top-level handler and converted to a 410 + // response with reason='kicked' + joinCode/className/seatNumber so the + // student UI can navigate back to seat selection with the right context. const session = await verifySessionToken(sessionToken); // Update lastActiveAt and extend TTL on each verify call @@ -1342,6 +1647,14 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { expect(status).toBe(204); }); }); + +// --------------------------------------------------------------------------- +// 教師フロー — 強制退室 (kick) と verify-session の reason 透過 +// --------------------------------------------------------------------------- +describeIfToken('教師フロー — 強制退室 (kick) と verify-session の reason 透過', () => { + let classroomId: string; + let joinCode: string; + let sessionTokenA: string; + let memberIdA: string; + + test('セットアップ: クラス作成', async () => { + const { status, data } = await request( + 'POST', + '/classrooms', + { className: 'Kick Test クラス', assignmentName: 'Kick 課題', studentCount: 5 }, + teacherHeaders, + ); + expect(status).toBe(201); + classroomId = data.classroomId as string; + joinCode = data.joinCode as string; + }); + + test('セットアップ: 生徒A が席1で参加', async () => { + const { status, data } = await request('POST', '/classrooms/join', { + joinCode, + seatNumber: 1, + nickname: '生徒A', + }); + expect(status).toBe(200); + sessionTokenA = data.sessionToken as string; + memberIdA = data.memberId as string; + }); + + test('生徒A の verify-session は 200', async () => { + const { status } = await request('POST', '/classrooms/verify-session', null, { + Authorization: `Bearer ${sessionTokenA}`, + }); + expect(status).toBe(200); + }); + + test('教師が生徒A を kick (DELETE /classrooms/{id}/members/{memberId}) → 204', async () => { + const { status } = await request( + 'DELETE', + `/classrooms/${classroomId}/members/${memberIdA}`, + null, + teacherHeaders, + ); + expect(status).toBe(204); + }); + + test('生徒A の verify-session は 410 reason=kicked + joinCode/className/seatNumber を返す', async () => { + const { status, data } = await request('POST', '/classrooms/verify-session', null, { + Authorization: `Bearer ${sessionTokenA}`, + }); + expect(status).toBe(410); + expect(data.reason).toBe('kicked'); + expect(data.joinCode).toBe(joinCode); + expect(data.className).toBe('Kick Test クラス'); + expect(data.seatNumber).toBe(1); + }); + + test('kick された席はメンバー一覧 (listMembers) には出てこない', async () => { + const { status, data } = await request( + 'GET', + `/classrooms/${classroomId}/members`, + null, + teacherHeaders, + ); + expect(status).toBe(200); + const members = data.members as Array<{ memberId: string }>; + expect(members.find(m => m.memberId === memberIdA)).toBeUndefined(); + }); + + test('kick された席は lookup の takenSeats に含まれない (席が空く)', async () => { + const { status, data } = await request('POST', '/classrooms/lookup', { joinCode }); + expect(status).toBe(200); + const takenSeats = data.takenSeats as number[]; + expect(takenSeats).not.toContain(1); + }); + + test('別の生徒B が同じ席1で join できる (kicked 行が上書きされる)', async () => { + const { status, data } = await request('POST', '/classrooms/join', { + joinCode, + seatNumber: 1, + nickname: '生徒B', + }); + expect(status).toBe(200); + expect(data.seatNumber).toBe(1); + expect(data.sessionToken).toBeDefined(); + expect(data.sessionToken).not.toBe(sessionTokenA); + }); + + test('上書き後、元の sessionToken (A) は 401 になる (tombstone は B の join で消滅)', async () => { + const { status } = await request('POST', '/classrooms/verify-session', null, { + Authorization: `Bearer ${sessionTokenA}`, + }); + expect(status).toBe(401); + }); + + // Cleanup + test('クリーンアップ: クラス削除', async () => { + const { status } = await request( + 'DELETE', + `/classrooms/${classroomId}`, + null, + teacherHeaders, + ); + expect(status).toBe(204); + }); +}); + +// --------------------------------------------------------------------------- +// 教師フロー — 退室リクエスト (kick request) +// --------------------------------------------------------------------------- +describeIfToken('教師フロー — 退室リクエスト (kick request)', () => { + let classroomId: string; + let joinCode: string; + let sessionTokenA: string; + let requestId: string; + let request2Id: string; + + test('セットアップ: クラス作成', async () => { + const { status, data } = await request( + 'POST', + '/classrooms', + { className: 'Kick Request Test', assignmentName: '退室依頼テスト', studentCount: 5 }, + teacherHeaders, + ); + expect(status).toBe(201); + classroomId = data.classroomId as string; + joinCode = data.joinCode as string; + }); + + test('セットアップ: 生徒A が席1で参加', async () => { + const { status, data } = await request('POST', '/classrooms/join', { + joinCode, + seatNumber: 1, + nickname: '生徒A', + }); + expect(status).toBe(200); + sessionTokenA = data.sessionToken as string; + }); + + test('GET /classrooms/{id}/kick-requests — 初期状態は空', async () => { + const { status, data } = await request( + 'GET', + `/classrooms/${classroomId}/kick-requests`, + null, + teacherHeaders, + ); + expect(status).toBe(200); + expect(data.requests).toEqual([]); + }); + + test('POST /classrooms/lookup/kick-request — joinCode + seatNumber でリクエスト送信 (認証不要)', async () => { + const { status, data } = await request('POST', '/classrooms/lookup/kick-request', { + joinCode, + seatNumber: 1, + reason: 'これは私の席です', + }); + expect(status).toBe(201); + expect(data.requestId).toBeDefined(); + expect(data.classroomId).toBe(classroomId); + expect(data.seatNumber).toBe(1); + requestId = data.requestId as string; + }); + + test('POST /classrooms/lookup/kick-request — 不正な joinCode で 404', async () => { + const { status } = await request('POST', '/classrooms/lookup/kick-request', { + joinCode: 'zzzzzz', + seatNumber: 1, + }); + expect(status).toBe(404); + }); + + test('POST /classrooms/lookup/kick-request — 範囲外の seatNumber で 400', async () => { + const { status } = await request('POST', '/classrooms/lookup/kick-request', { + joinCode, + seatNumber: 99, + }); + expect(status).toBe(400); + }); + + test('GET /classrooms/{id}/kick-requests — リクエスト 1 件返る (reason 付き)', async () => { + const { status, data } = await request( + 'GET', + `/classrooms/${classroomId}/kick-requests`, + null, + teacherHeaders, + ); + expect(status).toBe(200); + const requests = data.requests as Array<{ + requestId: string; + seatNumber: number; + reason: string | null; + createdAt: string; + }>; + expect(requests).toHaveLength(1); + expect(requests[0].requestId).toBe(requestId); + expect(requests[0].seatNumber).toBe(1); + expect(requests[0].reason).toBe('これは私の席です'); + }); + + test('POST /classrooms/lookup/kick-request — 同じ席に対する 2 回目のリクエストは別レコードとして許可される (abuse 規制なし、複数件許可仕様)', async () => { + const { status, data } = await request('POST', '/classrooms/lookup/kick-request', { + joinCode, + seatNumber: 1, + }); + expect(status).toBe(201); + expect(data.requestId).toBeDefined(); + expect(data.requestId).not.toBe(requestId); + request2Id = data.requestId as string; + }); + + test('GET /classrooms/{id}/kick-requests — リクエスト 2 件返る', async () => { + const { status, data } = await request( + 'GET', + `/classrooms/${classroomId}/kick-requests`, + null, + teacherHeaders, + ); + expect(status).toBe(200); + const requests = data.requests as Array<{ requestId: string }>; + expect(requests.map(r => r.requestId).sort()).toEqual([requestId, request2Id].sort()); + }); + + test('DELETE /classrooms/{id}/kick-requests/{requestId} — 教師が却下 → リクエスト削除のみでメンバー残る', async () => { + const { status } = await request( + 'DELETE', + `/classrooms/${classroomId}/kick-requests/${request2Id}`, + null, + teacherHeaders, + ); + expect(status).toBe(204); + + // 却下後: リクエストは 1 件に減る + const list = await request( + 'GET', + `/classrooms/${classroomId}/kick-requests`, + null, + teacherHeaders, + ); + const requests = list.data.requests as Array<{ requestId: string }>; + expect(requests.map(r => r.requestId)).toEqual([requestId]); + + // メンバー A はまだ参加中 (kick されていない) + const verifyA = await request('POST', '/classrooms/verify-session', null, { + Authorization: `Bearer ${sessionTokenA}`, + }); + expect(verifyA.status).toBe(200); + }); + + test('POST /classrooms/{id}/kick-requests/{requestId}/approve — 教師が承認 → 該当メンバー kick + リクエスト削除', async () => { + const { status } = await request( + 'POST', + `/classrooms/${classroomId}/kick-requests/${requestId}/approve`, + null, + teacherHeaders, + ); + expect(status).toBe(204); + + // 承認後: リクエスト一覧は空 + const list = await request( + 'GET', + `/classrooms/${classroomId}/kick-requests`, + null, + teacherHeaders, + ); + expect(list.data.requests).toEqual([]); + + // メンバー A の verify-session は 410 reason=kicked を返す (Phase 1 と同じ挙動) + const verifyA = await request('POST', '/classrooms/verify-session', null, { + Authorization: `Bearer ${sessionTokenA}`, + }); + expect(verifyA.status).toBe(410); + expect(verifyA.data.reason).toBe('kicked'); + }); + + test('POST /classrooms/lookup — activeKickRequestIds に未承認/未却下のリクエスト ID が含まれる', async () => { + // セットアップ: A は既に kick 済み (前のテストで承認された) → 新しい状況を作るため + // B を新しい席に参加させて kick request を作る + const joinB = await request('POST', '/classrooms/join', { + joinCode, + seatNumber: 4, + nickname: 'B-for-active-ids', + }); + expect(joinB.status).toBe(200); + + const reqC = await request('POST', '/classrooms/lookup/kick-request', { + joinCode, + seatNumber: 4, + }); + expect(reqC.status).toBe(201); + const reqId = reqC.data.requestId as string; + + const lookup = await request('POST', '/classrooms/lookup', { joinCode }); + expect(lookup.status).toBe(200); + expect(lookup.data.activeKickRequestIds).toEqual(expect.arrayContaining([reqId])); + + // 却下 → activeKickRequestIds から消える + const rejectRes = await request( + 'DELETE', + `/classrooms/${classroomId}/kick-requests/${reqId}`, + null, + teacherHeaders, + ); + expect(rejectRes.status).toBe(204); + + const lookup2 = await request('POST', '/classrooms/lookup', { joinCode }); + expect(lookup2.data.activeKickRequestIds).not.toContain(reqId); + }); + + test('POST /classrooms/lookup/kick-request — kick 済みの席に対するリクエストは席が空くので拒否される (404 — 席に占有者なし)', async () => { + const { status } = await request('POST', '/classrooms/lookup/kick-request', { + joinCode, + seatNumber: 1, + }); + expect(status).toBe(404); + }); + + test('認証エラー: 生徒がリクエスト一覧を見ようとすると 401', async () => { + const { status } = await request( + 'GET', + `/classrooms/${classroomId}/kick-requests`, + null, + { Authorization: `Bearer ${sessionTokenA}` }, + ); + expect(status).toBe(401); + }); + + test('認証エラー: 生徒が承認エンドポイントを叩こうとすると 401', async () => { + const { status } = await request( + 'POST', + `/classrooms/${classroomId}/kick-requests/anything/approve`, + null, + { Authorization: `Bearer ${sessionTokenA}` }, + ); + expect(status).toBe(401); + }); + + test('認証エラー: 別教師トークンで kick-requests を見ようとすると 401', async () => { + // 別 teacherSub をシミュレートできないので、無効トークンで代替 + const { status } = await request( + 'GET', + `/classrooms/${classroomId}/kick-requests`, + null, + { Authorization: 'Bearer invalid-teacher-token' }, + ); + expect(status).toBe(401); + }); + + // Cleanup + test('クリーンアップ: クラス削除', async () => { + const { status } = await request('DELETE', `/classrooms/${classroomId}`, null, teacherHeaders); + expect(status).toBe(204); + }); +}); diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index bd2d178739a..fe046e2e846 100644 --- a/infra/smalruby-classroom/lib/classroom-stack.ts +++ b/infra/smalruby-classroom/lib/classroom-stack.ts @@ -16,6 +16,7 @@ export class ClassroomStack extends cdk.Stack { public readonly classroomsTable: dynamodb.Table; public readonly membershipsTable: dynamodb.Table; public readonly submissionsTable: dynamodb.Table; + public readonly kickRequestsTable: dynamodb.Table; public readonly submissionsBucket: s3.Bucket; public readonly api: apigatewayv2.HttpApi; @@ -156,6 +157,44 @@ export class ClassroomStack extends cdk.Stack { cdk.Tags.of(this.submissionsTable).add('ResourceType', 'DynamoDB'); + // Kick requests table — short-lived (1h TTL) records of students asking + // the teacher to free up a specific seat. PK is classroomId; SK is the + // requestId (UUID) so multiple pending requests for the same seat + // coexist. A GSI on (classroomId, seatNumber) lets the approve handler + // delete sibling requests for the same seat in one query after kick. + this.kickRequestsTable = new dynamodb.Table(this, 'KickRequestsTable', { + tableName: `ClassroomKickRequests${stageSuffix}`, + partitionKey: { + name: 'classroomId', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'requestId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: false, + }, + timeToLiveAttribute: 'ttl', + }); + + this.kickRequestsTable.addGlobalSecondaryIndex({ + indexName: 'classroomId-seatNumber-index', + partitionKey: { + name: 'classroomId', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'seatNumber', + type: dynamodb.AttributeType.NUMBER, + }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + cdk.Tags.of(this.kickRequestsTable).add('ResourceType', 'DynamoDB'); + // --- S3 Bucket for submissions --- this.submissionsBucket = new s3.Bucket(this, 'SubmissionsBucket', { @@ -201,6 +240,7 @@ export class ClassroomStack extends cdk.Stack { CLASSROOMS_TABLE_NAME: this.classroomsTable.tableName, MEMBERSHIPS_TABLE_NAME: this.membershipsTable.tableName, SUBMISSIONS_TABLE_NAME: this.submissionsTable.tableName, + KICK_REQUESTS_TABLE_NAME: this.kickRequestsTable.tableName, SUBMISSIONS_BUCKET_NAME: this.submissionsBucket.bucketName, GOOGLE_CLIENT_ID: googleClientId, MICROSOFT_CLIENT_ID: microsoftClientId, @@ -225,6 +265,7 @@ export class ClassroomStack extends cdk.Stack { this.classroomsTable.grantReadWriteData(handlerFn); this.membershipsTable.grantReadWriteData(handlerFn); this.submissionsTable.grantReadWriteData(handlerFn); + this.kickRequestsTable.grantReadWriteData(handlerFn); this.submissionsBucket.grantPut(handlerFn); this.submissionsBucket.grantRead(handlerFn); @@ -326,6 +367,31 @@ export class ClassroomStack extends cdk.Stack { integration, }); + // Kick requests — students ask the teacher to free a specific seat. + this.api.addRoutes({ + path: '/classrooms/lookup/kick-request', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + + this.api.addRoutes({ + path: '/classrooms/{classroomId}/kick-requests', + methods: [apigatewayv2.HttpMethod.GET], + integration, + }); + + this.api.addRoutes({ + path: '/classrooms/{classroomId}/kick-requests/{requestId}', + methods: [apigatewayv2.HttpMethod.DELETE], + integration, + }); + + this.api.addRoutes({ + path: '/classrooms/{classroomId}/kick-requests/{requestId}/approve', + methods: [apigatewayv2.HttpMethod.POST], + integration, + }); + this.api.addRoutes({ path: '/classrooms/{classroomId}/submissions', methods: [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.GET], diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index b9f9f9dfaf4..82d3fa86f96 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -88,6 +88,7 @@ src/containers/* !src/containers/ruby-tab/ # Individual Smalruby files !src/containers/block-display-modal.jsx +!src/containers/classroom-classcode-utils.js !src/containers/classroom-error-utils.js !src/containers/classroom-modal.jsx !src/containers/classroom-teacher-modal.jsx @@ -123,6 +124,7 @@ src/lib/* !src/lib/backpack-mesh-v1-migration.js !src/lib/deck-setup.js !src/lib/classroom-api.js +!src/lib/classroom-kick-request-storage.js !src/lib/google-classroom-auth.js !src/lib/block-utils.js !src/lib/blocks-gesture-recovery.js @@ -284,6 +286,8 @@ test/unit/containers/* !test/unit/containers/ruby-tab/ !test/unit/containers/backpack.test.jsx !test/unit/containers/cards.test.jsx +!test/unit/containers/classroom-classcode-utils.test.js +!test/unit/containers/classroom-error-utils.test.js !test/unit/containers/connection-modal-connected-message.test.jsx !test/unit/containers/connection-modal-smalrubot-s1.test.jsx !test/unit/containers/connection-modal.test.jsx @@ -310,6 +314,8 @@ test/unit/lib/* !test/unit/lib/block-display-initialization.test.js !test/unit/lib/blockly-private-api.test.js !test/unit/lib/blocks-gesture-recovery.test.js +!test/unit/lib/classroom-api.test.js +!test/unit/lib/classroom-kick-request-storage.test.js !test/unit/lib/deck-setup.test.js !test/unit/lib/blocks-screenshot.test.js !test/unit/lib/calculate-popup-position.test.js diff --git a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css index b7e58a899cd..899328f6b85 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css @@ -515,6 +515,226 @@ margin-top: 0.5rem; } +.kick-request-panel { + margin-top: 1rem; + padding: 0.8rem 1rem; + background-color: #fff4e5; + border: 1px solid #ffa000; + border-radius: 8px; + color: #4a3500; +} + +.kick-request-panel-title { + font-weight: bold; + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.kick-request-panel-row { + padding: 0.4rem 0; + border-top: 1px solid #ffd180; +} + +.kick-request-panel-row:first-of-type { + border-top: none; +} + +.kick-request-panel-reason { + font-size: 0.85rem; + margin-bottom: 0.4rem; + color: #4a3500; +} + +.kick-request-panel-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.kick-request-approve-button { + background-color: #d32f2f; + color: #fff; + border: none; + border-radius: 4px; + padding: 0.4rem 0.8rem; + font-size: 0.85rem; + cursor: pointer; +} + +.kick-request-approve-button:hover { + background-color: #b71c1c; +} + +.kick-request-approve-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.kick-request-reject-button { + background-color: #fff; + color: #575e75; + border: 1px solid #b8b8b8; + border-radius: 4px; + padding: 0.4rem 0.8rem; + font-size: 0.85rem; + cursor: pointer; +} + +.kick-request-reject-button:hover { + background-color: #f0f0f0; +} + +.kick-request-reject-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.seat-kick-request-badge { + position: absolute; + top: -4px; + right: -4px; + background-color: #d32f2f; + color: #fff; + font-size: 0.7rem; + font-weight: bold; + border-radius: 50%; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + line-height: 1; +} + +.kicked-banner { + display: flex; + align-items: flex-start; + gap: 0.5rem; + background-color: #fff4e5; + border: 1px solid #ffa000; + border-radius: 8px; + padding: 0.8rem 1rem; + margin-bottom: 1rem; + color: #4a3500; +} + +.kick-request-rejected-banner { + display: flex; + align-items: flex-start; + gap: 0.5rem; + background-color: #fdecea; + border: 1px solid #f44336; + border-radius: 8px; + padding: 0.8rem 1rem; + margin-bottom: 1rem; + color: #5d1a1a; + font-size: 0.9rem; + line-height: 1.5; +} + +.kick-request-rejected-text { + flex: 1; +} + +.kick-request-pending-banner { + display: flex; + align-items: center; + gap: 0.5rem; + background-color: #e3f2fd; + border: 1px solid #2196f3; + border-radius: 8px; + padding: 0.8rem 1rem; + margin-bottom: 1rem; + color: #0d47a1; + font-size: 0.9rem; + line-height: 1.5; +} + +.kick-request-dialog { + background-color: #fff; + border-radius: 8px; + padding: 1rem 1.2rem; + margin: 0 auto; + max-width: 28rem; +} + +.kick-request-dialog-title { + font-size: 1.05rem; + margin: 0 0 0.6rem 0; + color: #1a1a4e; +} + +.kick-request-dialog-body { + font-size: 0.9rem; + color: #575e75; + margin: 0 0 0.8rem 0; + line-height: 1.5; +} + +.kick-request-reason-input { + width: 100%; + box-sizing: border-box; + font-size: 0.9rem; + font-family: inherit; + padding: 0.5rem; + border: 1px solid #d9d9d9; + border-radius: 4px; + resize: vertical; +} + +.kick-request-dialog-error { + margin-top: 0.5rem; + color: #d32f2f; + font-size: 0.85rem; +} + +.kick-request-dialog-buttons { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + margin-top: 1rem; +} + +.secondary-button { + background-color: #fff; + color: #575e75; + border: 1px solid #b8b8b8; + border-radius: 0.5rem; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9rem; +} + +.secondary-button:hover { + background-color: #f0f0f0; +} + +.secondary-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.kicked-banner-text { + flex: 1; + font-size: 0.9rem; + line-height: 1.5; +} + +.kicked-banner-dismiss { + background: none; + border: none; + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + color: #4a3500; + padding: 0 0.2rem; +} + +.kicked-banner-dismiss:hover { + color: #000; +} + .error-box { background-color: #fff0f3; border: 1px solid #ff6680; @@ -632,11 +852,16 @@ cursor: pointer; border: none; padding: 0; + position: relative; } +/* Seat colors: align with the student-side seat selector so the same visual + * mapping holds across roles — blue means "available", grey means "occupied + * but no submission yet". Submitted / returned keep their own colors since + * they're teacher-only states the student never sees. */ .member-cell-joined { - background-color: #4c97ff; - color: white; + background-color: #d9d9d9; + color: #555; } .member-cell-submitted { @@ -650,8 +875,8 @@ } .member-cell-empty { - background-color: #e8e8e8; - color: #999; + background-color: #4c97ff; + color: white; cursor: default; } diff --git a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx index 2373a48f297..1d15bc0d06d 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx @@ -27,6 +27,11 @@ const ClassroomModal = ({ takenSeats, selectedSeat, joinedInfo, + kickedNotice, + kickRequestDialogSeat, + kickRequestPending, + kickRequestRejectedNotice, + kickRequestError, error, errorActionHandler, errorActionLabel, @@ -38,6 +43,11 @@ const ClassroomModal = ({ onSelectSeat, onConfirmJoin, onClose, + onDismissKickRequestRejectedNotice, + onDismissKickedNotice, + onRequestKick, + onConfirmKickRequest, + onCancelKickRequest, onLeaveClassroom, onStartSubmit, onConfirmSubmit, @@ -79,10 +89,20 @@ const ClassroomModal = ({ error={error} errorTitle={errorTitle} isLoading={isLoading} + kickedNotice={kickedNotice} + kickRequestDialogSeat={kickRequestDialogSeat} + kickRequestError={kickRequestError} + kickRequestPending={kickRequestPending} + kickRequestRejectedNotice={kickRequestRejectedNotice} seatCount={seatCount} selectedSeat={selectedSeat} takenSeats={takenSeats} + onCancelKickRequest={onCancelKickRequest} onConfirmJoin={onConfirmJoin} + onConfirmKickRequest={onConfirmKickRequest} + onDismissKickRequestRejectedNotice={onDismissKickRequestRejectedNotice} + onDismissKickedNotice={onDismissKickedNotice} + onRequestKick={onRequestKick} onSelectSeat={onSelectSeat} /> )} @@ -147,11 +167,31 @@ ClassroomModal.propTypes = { className: PropTypes.string, seatNumber: PropTypes.number, }), + kickedNotice: PropTypes.shape({ + joinCode: PropTypes.string, + className: PropTypes.string, + seatNumber: PropTypes.number, + }), + kickRequestDialogSeat: PropTypes.number, + kickRequestError: PropTypes.string, + kickRequestPending: PropTypes.shape({ + requestId: PropTypes.string, + joinCode: PropTypes.string, + seatNumber: PropTypes.number, + }), + kickRequestRejectedNotice: PropTypes.shape({ + seatNumber: PropTypes.number, + }), + onCancelKickRequest: PropTypes.func, onCancelSubmit: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, onConfirmJoin: PropTypes.func.isRequired, + onConfirmKickRequest: PropTypes.func, onConfirmSubmit: PropTypes.func.isRequired, + onDismissKickRequestRejectedNotice: PropTypes.func, + onDismissKickedNotice: PropTypes.func, onJoinWithCode: PropTypes.func.isRequired, + onRequestKick: PropTypes.func, onLeaveClassroom: PropTypes.func.isRequired, onRefreshStudentStatus: PropTypes.func.isRequired, onSelectSeat: PropTypes.func.isRequired, diff --git a/packages/scratch-gui/src/components/classroom-modal/kick-request-confirm-dialog.jsx b/packages/scratch-gui/src/components/classroom-modal/kick-request-confirm-dialog.jsx new file mode 100644 index 00000000000..5ec2b6a8163 --- /dev/null +++ b/packages/scratch-gui/src/components/classroom-modal/kick-request-confirm-dialog.jsx @@ -0,0 +1,103 @@ +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; + +import styles from './classroom-modal.css'; + +const messages = defineMessages({ + reasonPlaceholder: { + defaultMessage: 'Optional: tell the teacher why you need this seat (max 200 chars).', + description: 'Placeholder text in the kick-request reason textarea', + id: 'gui.classroom.kickRequest.reasonPlaceholder', + }, +}); + +const MAX_REASON_LENGTH = 200; + +const KickRequestConfirmDialog = ({ seatNumber, isLoading, error, onCancel, onConfirm }) => { + const intl = useIntl(); + const [reason, setReason] = useState(''); + + const handleReasonChange = useCallback((e) => { + const value = e.target.value; + setReason(value.length > MAX_REASON_LENGTH ? value.slice(0, MAX_REASON_LENGTH) : value); + }, []); + + const handleConfirm = useCallback(() => { + const trimmed = reason.trim(); + onConfirm(trimmed === '' ? null : trimmed); + }, [onConfirm, reason]); + + return ( +
+

+ +

+

+ +

+