From fe52d83d9f3054aeaccf4e9092450a93eee139ba Mon Sep 17 00:00:00 2001 From: jtoxyz <0qb3671k78f477n@gmail.com> Date: Fri, 24 Jul 2026 23:48:32 +0900 Subject: [PATCH 1/4] Trigger university Google auth implementation --- .github/google-auth-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/google-auth-trigger diff --git a/.github/google-auth-trigger b/.github/google-auth-trigger new file mode 100644 index 0000000..f5bdd21 --- /dev/null +++ b/.github/google-auth-trigger @@ -0,0 +1 @@ +run From ec0dd2c1b4131be0c2a0e5861a58a75db56417b1 Mon Sep 17 00:00:00 2001 From: jtoxyz <0qb3671k78f477n@gmail.com> Date: Fri, 24 Jul 2026 23:50:32 +0900 Subject: [PATCH 2/4] Capture Google auth build output --- .../workflows/implement-google-university-auth.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/implement-google-university-auth.yml b/.github/workflows/implement-google-university-auth.yml index d607d46..5ac26a6 100644 --- a/.github/workflows/implement-google-university-auth.yml +++ b/.github/workflows/implement-google-university-auth.yml @@ -31,7 +31,16 @@ jobs: run: npm ci - name: Build - run: npm run build + run: | + set -o pipefail + npm run build 2>&1 | tee build.log + + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: google-auth-build-log + path: build.log - name: Commit changes to pull request branch run: | From 867df8c5da4d72cd4c84e12fabcce707a0dcab10 Mon Sep 17 00:00:00 2001 From: jtoxyz <0qb3671k78f477n@gmail.com> Date: Fri, 24 Jul 2026 23:52:29 +0900 Subject: [PATCH 3/4] Provide build-time Supabase environment --- .github/workflows/implement-google-university-auth.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/implement-google-university-auth.yml b/.github/workflows/implement-google-university-auth.yml index 5ac26a6..204b703 100644 --- a/.github/workflows/implement-google-university-auth.yml +++ b/.github/workflows/implement-google-university-auth.yml @@ -11,6 +11,10 @@ on: permissions: contents: write +env: + NEXT_PUBLIC_SUPABASE_URL: https://nbhpsjhqaakaweeywhxa.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: build-placeholder-key + jobs: implement: runs-on: ubuntu-latest From 19ca0042f018718917a43eb6335101e25eceb420 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:53:15 +0000 Subject: [PATCH 4/4] Require university Google authentication for reservations --- src/app/events/[id]/page.tsx | 260 ++++++++---------- ...4000300_require_google_university_auth.sql | 63 +++++ 2 files changed, 179 insertions(+), 144 deletions(-) create mode 100644 supabase/migrations/20260724000300_require_google_university_auth.sql diff --git a/src/app/events/[id]/page.tsx b/src/app/events/[id]/page.tsx index 5a221eb..c520693 100644 --- a/src/app/events/[id]/page.tsx +++ b/src/app/events/[id]/page.tsx @@ -4,11 +4,12 @@ export const runtime = 'edge'; import { use, useEffect, useState } from 'react'; +import type { Session } from '@supabase/supabase-js'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { supabase } from '@/lib/supabase'; import { saveToken } from '@/lib/ticketCache'; -import { ALLOWED_EMAIL_DOMAINS, STUDENT_EMAIL_DOMAIN } from '@/lib/config'; +import { STUDENT_EMAIL_DOMAIN } from '@/lib/config'; import { marked } from 'marked'; import DOMPurify from 'isomorphic-dompurify'; import { parseShortcodes } from '@/lib/parseShortcodes'; @@ -121,7 +122,61 @@ export default function EventBookingPage({ params }: { params: Promise<{ id: str const [studentName, setStudentName] = useState(''); const [studentNumber, setStudentNumber] = useState(''); const [universityEmail, setUniversityEmail] = useState(''); - const [isEmailEdited, setIsEmailEdited] = useState(false); + const [authSession, setAuthSession] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + + useEffect(() => { + let mounted = true; + + const applySession = (session: Session | null) => { + if (!mounted) return; + setAuthSession(session); + const email = session?.user?.email?.trim().toLowerCase() ?? ''; + const match = email.match(/^s([0-9]{2}[a-z][0-9]{3})@ge\.osaka-sandai\.ac\.jp$/i); + if (match) { + setUniversityEmail(email); + setStudentNumber(match[1].toUpperCase()); + setError(null); + } else { + setUniversityEmail(''); + setStudentNumber(''); + if (session) { + setError('大阪産業大学のGoogleアカウントでログインしてください。'); + } + } + setAuthLoading(false); + }; + + supabase.auth.getSession().then(({ data }) => applySession(data.session)); + const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => applySession(session)); + + return () => { + mounted = false; + listener.subscription.unsubscribe(); + }; + }, []); + + const handleGoogleLogin = async () => { + setError(null); + const { error: signInError } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: window.location.href, + queryParams: { + hd: STUDENT_EMAIL_DOMAIN, + prompt: 'select_account', + }, + }, + }); + if (signInError) setError('Googleログインを開始できませんでした。'); + }; + + const handleLogout = async () => { + await supabase.auth.signOut(); + setStudentName(''); + setStudentNumber(''); + setUniversityEmail(''); + }; useEffect(() => { async function fetchEventDetails() { @@ -171,39 +226,6 @@ export default function EventBookingPage({ params }: { params: Promise<{ id: str fetchEventDetails(); }, [id]); - // Handle student number change (remove whitespace, no capitalization during typing to prevent IME duplication) - const handleStudentNumberChange = (e: React.ChangeEvent) => { - let rawVal = e.target.value; - - // Remove all whitespace - rawVal = rawVal.replace(/\s+/g, ''); - - setStudentNumber(rawVal); - - // Auto-generate email based on normalized student number - const normalized = normalizeStudentNumber(rawVal); - if (normalized.trim() !== '') { - const emailUserPart = 's' + normalized.toLowerCase(); - setUniversityEmail(`${emailUserPart}@${STUDENT_EMAIL_DOMAIN}`); - setIsEmailEdited(false); - } else { - setUniversityEmail(''); - setIsEmailEdited(false); - } - }; - - const handleStudentNumberBlur = (e: React.FocusEvent) => { - const rawVal = e.target.value; - const normalized = normalizeStudentNumber(rawVal); - setStudentNumber(normalized); - }; - - // Handle email changes (mark as manually modified) - const handleEmailChange = (e: React.ChangeEvent) => { - setUniversityEmail(e.target.value); - setIsEmailEdited(true); - }; - const handleSlotToggle = (slotId: string) => { if (!event) return; if (event.slot_selection_mode === 'single') { @@ -227,40 +249,21 @@ export default function EventBookingPage({ params }: { params: Promise<{ id: str const cleanEmail = universityEmail.trim().toLowerCase(); // Validations - if (!cleanName || !normalizedNumber || !cleanEmail) { - setError('すべての項目を入力してください。'); - setBooking(false); - return; - } - - // Validate at least one slot is selected - if (selectedSlotIds.length === 0) { - setError('参加する枠を選択してください。'); - setBooking(false); - return; - } - - // Student number regex validation - const studentNumberRegex = /^\d{2}[A-Z]\d{3}$/; - if (!studentNumberRegex.test(normalizedNumber)) { - setError('学籍番号は「数字2桁 + 英字1文字 + 数字3桁」の形式で入力してください。(例: 24B123)'); + if (!authSession) { + setError('大学Googleアカウントでログインしてください。'); setBooking(false); return; } - // Verify email matches student number - const expectedEmailLocalPart = 's' + normalizedNumber.toLowerCase(); - const actualEmailLocalPart = cleanEmail.split('@')[0]; - if (actualEmailLocalPart !== expectedEmailLocalPart) { - setError('メールアドレスのユーザー名(@の左側)が学籍番号と一致しません。'); + if (!cleanName || !normalizedNumber || !cleanEmail) { + setError('氏名を入力してください。'); setBooking(false); return; } - // Domain validation on frontend - const domain = cleanEmail.split('@')[1]; - if (!domain || !ALLOWED_EMAIL_DOMAINS.includes(domain)) { - setError(`許可されている大学のメールアドレスを入力してください。(${ALLOWED_EMAIL_DOMAINS.join(', ')})`); + // Validate at least one slot is selected + if (selectedSlotIds.length === 0) { + setError('参加する枠を選択してください。'); setBooking(false); return; } @@ -337,40 +340,21 @@ export default function EventBookingPage({ params }: { params: Promise<{ id: str const cleanEmail = universityEmail.trim().toLowerCase(); // Validations - if (!cleanName || !normalizedNumber || !cleanEmail) { - setError('すべての項目を入力してください。'); - setBooking(false); - return; - } - - // Validate exactly one slot is selected - if (selectedSlotIds.length !== 1) { - setError('当日券を取得する枠を1つだけ選択してください。'); - setBooking(false); - return; - } - - // Student number regex validation - const studentNumberRegex = /^\d{2}[A-Z]\d{3}$/; - if (!studentNumberRegex.test(normalizedNumber)) { - setError('学籍番号は「数字2桁 + 英字1文字 + 数字3桁」の形式で入力してください。(例: 24B123)'); + if (!authSession) { + setError('大学Googleアカウントでログインしてください。'); setBooking(false); return; } - // Verify email matches student number - const expectedEmailLocalPart = 's' + normalizedNumber.toLowerCase(); - const actualEmailLocalPart = cleanEmail.split('@')[0]; - if (actualEmailLocalPart !== expectedEmailLocalPart) { - setError('メールアドレスのユーザー名(@の左側)が学籍番号と一致しません。'); + if (!cleanName || !normalizedNumber || !cleanEmail) { + setError('氏名を入力してください。'); setBooking(false); return; } - // Domain validation on frontend - const domain = cleanEmail.split('@')[1]; - if (!domain || !ALLOWED_EMAIL_DOMAINS.includes(domain)) { - setError(`許可されている大学のメールアドレスを入力してください。(${ALLOWED_EMAIL_DOMAINS.join(', ')})`); + // Validate exactly one slot is selected + if (selectedSlotIds.length !== 1) { + setError('当日券を取得する枠を1つだけ選択してください。'); setBooking(false); return; } @@ -715,61 +699,49 @@ export default function EventBookingPage({ params }: { params: Promise<{ id: str )} -
- - setStudentName(e.target.value)} - disabled={booking} - /> -
- -
- - - - 例:24B123(先頭の s は入力しない) - -
+ {authLoading ? ( +
ログイン状態を確認しています...
+ ) : !authSession || !universityEmail ? ( +
+

+ 予約・当日券の取得には、大阪産業大学のGoogleアカウントでログインしてください。 +

+ +
+ ) : ( + <> +
+
大学アカウントで認証済み
+
+ 学籍番号:{studentNumber}
+ 大学メール:{universityEmail} +
+ +
-
- - - {!isEmailEdited && studentNumber && ( - - 💡 学籍番号から自動入力しています。違う場合は修正してください。 - - )} -
+
+ + setStudentName(e.target.value)} + disabled={booking} + autoComplete="name" + /> +
+ + )}
{booking ? '予約処理中...' : '予約券を取得する'} @@ -800,7 +772,7 @@ export default function EventBookingPage({ params }: { params: Promise<{ id: str type="button" className="btn btn-secondary" onClick={handleWalkinSubmit} - disabled={booking || !isWalkinButtonEnabled} + disabled={booking || authLoading || !authSession || !universityEmail || !studentName.trim() || !isWalkinButtonEnabled} style={{ borderColor: isWalkinButtonEnabled ? 'var(--color-warning-border)' : 'var(--card-border)', color: isWalkinButtonEnabled ? 'var(--color-warning)' : 'var(--text-muted)', diff --git a/supabase/migrations/20260724000300_require_google_university_auth.sql b/supabase/migrations/20260724000300_require_google_university_auth.sql new file mode 100644 index 0000000..9f03349 --- /dev/null +++ b/supabase/migrations/20260724000300_require_google_university_auth.sql @@ -0,0 +1,63 @@ +BEGIN; + +CREATE OR REPLACE FUNCTION public.enforce_verified_university_identity() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_email text; + v_student_number text; + v_role text; +BEGIN + v_role := COALESCE(current_setting('request.jwt.claim.role', true), ''); + + -- Service-role and administrators may create/manage records without student OAuth. + IF v_role = 'service_role' OR EXISTS ( + SELECT 1 FROM public.admin_users au WHERE au.id = auth.uid() + ) THEN + RETURN NEW; + END IF; + + IF auth.uid() IS NULL THEN + RAISE EXCEPTION '大学Googleアカウントでログインしてください。'; + END IF; + + SELECT lower(u.email) + INTO v_email + FROM auth.users u + WHERE u.id = auth.uid() + AND u.email_confirmed_at IS NOT NULL; + + IF v_email IS NULL THEN + RAISE EXCEPTION '確認済みの大学Googleアカウントが必要です。'; + END IF; + + IF v_email !~ '^s[0-9]{2}[a-z][0-9]{3}@ge\\.osaka-sandai\\.ac\\.jp$' THEN + RAISE EXCEPTION '大阪産業大学のGoogleアカウントでログインしてください。'; + END IF; + + v_student_number := upper(substring(v_email from '^s([0-9]{2}[a-z][0-9]{3})@')); + NEW.university_email := v_email; + NEW.student_number := v_student_number; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS reservations_verified_university_identity ON public.reservations; +CREATE TRIGGER reservations_verified_university_identity +BEFORE INSERT ON public.reservations +FOR EACH ROW EXECUTE FUNCTION public.enforce_verified_university_identity(); + +REVOKE ALL ON FUNCTION public.enforce_verified_university_identity() FROM PUBLIC, anon, authenticated; + +REVOKE EXECUTE ON FUNCTION public.create_reservation(uuid, uuid, text, text, text, text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.create_walkin_reservation(uuid, uuid, text, text, text, text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.create_reservations_bulk(uuid, uuid[], text, text, text, text) FROM anon; +GRANT EXECUTE ON FUNCTION public.create_reservation(uuid, uuid, text, text, text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.create_walkin_reservation(uuid, uuid, text, text, text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.create_reservations_bulk(uuid, uuid[], text, text, text, text) TO authenticated; + +NOTIFY pgrst, 'reload schema'; +COMMIT;