feat(auth): integrate Firebase authentication and add auth status component

- Added Firebase as a dependency for authentication.
- Created AuthProvider to manage authentication state and user sessions.
- Implemented AuthStatus component to display authentication status in the UI.
- Added verifyFirebaseIdToken function to validate Firebase ID tokens against the backend.
- Updated API endpoints to include Firebase OAuth verification.
- Enhanced ShowsPage to allow authenticated users to delete shows.
- Updated configuration to include Firebase settings and authentication enablement.
- Styled authentication components and added necessary CSS for better UI.
This commit is contained in:
Nik Afiq 2025-12-10 21:16:14 +09:00
parent 8b268640a5
commit 008f8a3cca
15 changed files with 1369 additions and 14 deletions

View File

@ -26,6 +26,14 @@ ADDR=:8082
# Auth (optional)
AUTH_ENABLED=false
FIREBASE_PROJECT_ID=
FIREBASE_CREDENTIALS_FILE=/secrets/firebase_credentials.json
# Use one of the following to supply Firebase service account:
# FIREBASE_CREDENTIALS_JSON=
# FIREBASE_CREDENTIALS_FILE=/path/to/firebase.json
# Frontend Firebase (for Google sign-in)
VITE_AUTH_ENABLED=true
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_APP_ID=

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"firebase": "^12.6.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.5"

View File

@ -5,6 +5,7 @@ import ShowsPage from "./pages/ShowsPage";
import TimeSyncNotice from "./components/TimeSyncNotice";
import { ToastViewport } from "./components/Toasts";
import DebugOverlay from "./components/DebugOverlay";
import AuthStatus from "./components/AuthStatus";
import "./index.css";
const TIME_SYNC_OFF_THRESHOLD = 100;
@ -36,6 +37,9 @@ export default function App() {
</svg>
</button>
<Link to="/" className="brand">Watch Party</Link>
<div className="auth-slot">
<AuthStatus />
</div>
</header>
{/* Time sync banner (checks every 5 min; shows if |skew| > 500ms) */}
<TimeSyncNotice thresholdMs={TIME_SYNC_OFF_THRESHOLD} lang="ja" />

13
frontend/src/api/auth.ts Normal file
View File

@ -0,0 +1,13 @@
import { API_ENDPOINT } from "./endpoint";
import { apiFetch } from "./client";
import type { FirebaseAuthResponse } from "./types";
export async function verifyFirebaseIdToken(idToken: string): Promise<FirebaseAuthResponse> {
return apiFetch<FirebaseAuthResponse>(API_ENDPOINT.v1.OAUTH_FIREBASE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id_token: idToken }),
timeoutMs: 10_000,
logLabel: "verify firebase token",
});
}

View File

@ -6,5 +6,6 @@ export const API_ENDPOINT = {
SHOWS: buildApiUrl("/v1/shows"),
TIME: buildApiUrl("/v1/time"),
DANIME: buildApiUrl("/v1/danime"),
OAUTH_FIREBASE: buildApiUrl("/v1/oauth/firebase"),
},
} as const;

View File

@ -28,3 +28,10 @@ export type DanimeEpisode = {
season_name: string;
playback_length: string;
};
export type FirebaseAuthResponse = {
uid: string;
email?: string;
issuer?: string;
expires?: number;
};

View File

@ -136,3 +136,19 @@ export async function createShow(payload: {
logLabel: "create show",
});
}
export async function deleteShow(id: number, idToken: string) {
if (!idToken) {
throw new ApiError("Missing auth token for delete");
}
const url = `${API_ENDPOINT.v1.SHOWS}?id=${encodeURIComponent(id)}`;
await apiFetch<void>(url, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${idToken}`,
},
timeoutMs: 10_000,
expect: "void",
logLabel: "delete show",
});
}

View File

@ -0,0 +1,126 @@
import React from "react";
import { GoogleAuthProvider, getAuth, getIdToken, onIdTokenChanged, signInWithPopup, signOut as fbSignOut, type User } from "firebase/auth";
import { getFirebaseApp } from "./firebaseClient";
import { config } from "../config";
import { verifyFirebaseIdToken } from "../api/auth";
import type { FirebaseAuthResponse } from "../api/types";
type AuthStatus = "disabled" | "loading" | "ready" | "error";
type AuthContextShape = {
enabled: boolean;
status: AuthStatus;
user: User | null;
idToken: string | null;
backendClaims: FirebaseAuthResponse | null;
verifying: boolean;
error: string | null;
signInWithGoogle: () => Promise<void>;
signOut: () => Promise<void>;
refreshToken: () => Promise<void>;
};
const AuthContext = React.createContext<AuthContextShape | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const enabled = config.auth.enabled;
const [status, setStatus] = React.useState<AuthStatus>(enabled ? "loading" : "disabled");
const [user, setUser] = React.useState<User | null>(null);
const [idToken, setIdToken] = React.useState<string | null>(null);
const [backendClaims, setBackendClaims] = React.useState<FirebaseAuthResponse | null>(null);
const [verifying, setVerifying] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// Subscribe to Firebase auth state
React.useEffect(() => {
if (!enabled) return;
const app = getFirebaseApp();
const auth = getAuth(app);
const unsub = onIdTokenChanged(auth, async (fbUser) => {
setUser(fbUser);
setBackendClaims(null);
if (!fbUser) {
setIdToken(null);
setStatus("ready");
return;
}
try {
const token = await getIdToken(fbUser, true);
setIdToken(token);
setVerifying(true);
const claims = await verifyFirebaseIdToken(token);
setBackendClaims(claims);
setError(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to verify token";
setError(msg);
} finally {
setVerifying(false);
setStatus("ready");
}
});
return () => unsub();
}, [enabled]);
const signInWithGoogle = React.useCallback(async () => {
if (!enabled) {
setError("Auth disabled");
return;
}
const app = getFirebaseApp();
const auth = getAuth(app);
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
}, [enabled]);
const signOut = React.useCallback(async () => {
if (!enabled) return;
const app = getFirebaseApp();
const auth = getAuth(app);
await fbSignOut(auth);
setBackendClaims(null);
setIdToken(null);
}, [enabled]);
const refreshToken = React.useCallback(async () => {
if (!enabled || !user) return;
const token = await getIdToken(user, true);
setIdToken(token);
setVerifying(true);
try {
const claims = await verifyFirebaseIdToken(token);
setBackendClaims(claims);
setError(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to verify token";
setError(msg);
} finally {
setVerifying(false);
}
}, [enabled, user]);
const value: AuthContextShape = React.useMemo(() => ({
enabled,
status,
user,
idToken,
backendClaims,
verifying,
error,
signInWithGoogle,
signOut,
refreshToken,
}), [enabled, status, user, idToken, backendClaims, verifying, error, signInWithGoogle, signOut, refreshToken]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = React.useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@ -0,0 +1,17 @@
import { getApp, getApps, initializeApp, type FirebaseApp } from "firebase/app";
import { config } from "../config";
let cachedApp: FirebaseApp | null = null;
export function getFirebaseApp(): FirebaseApp {
if (cachedApp) return cachedApp;
if (!config.auth.enabled) {
throw new Error("Firebase auth not enabled");
}
if (!getApps().length) {
cachedApp = initializeApp(config.auth.firebase);
} else {
cachedApp = getApp();
}
return cachedApp;
}

View File

@ -0,0 +1,33 @@
import { useAuth } from "../auth/AuthProvider";
export default function AuthStatus() {
const { enabled, status, user, backendClaims, verifying, signInWithGoogle, signOut, error } = useAuth();
if (!enabled) {
return <div className="auth-chip muted">Auth off</div>;
}
if (status === "loading") {
return <div className="auth-chip">Loading auth</div>;
}
if (!user) {
return (
<button className="auth-btn" onClick={() => signInWithGoogle().catch(() => {})}>
<span className="auth-icon">G</span>
<span>Google </span>
</button>
);
}
return (
<div className="auth-chip signed-in">
{user.photoURL && <img src={user.photoURL} alt="" className="auth-avatar" referrerPolicy="no-referrer" />}
<div className="auth-meta">
<div className="auth-name">{user.displayName || user.email || user.uid}</div>
<div className="auth-subtle">
{verifying ? "確認中…" : backendClaims ? "バックエンド認証済み" : "未確認"}
</div>
{error && <div className="auth-error">{error}</div>}
</div>
<button className="auth-signout" onClick={() => signOut().catch(() => {})}></button>
</div>
);
}

View File

@ -4,6 +4,10 @@ type AppConfig = {
basePath: string;
apiBase: string;
backendOrigin: string;
auth: {
enabled: boolean;
firebase: FirebaseWebConfig;
};
intervals: {
timeSyncMs: number;
timeSkewMs: number;
@ -11,6 +15,13 @@ type AppConfig = {
};
};
type FirebaseWebConfig = {
apiKey: string;
authDomain: string;
projectId: string;
appId: string;
};
export function normalizeBasePath(raw?: string) {
let v = (raw || "/").trim();
if (!v.startsWith("/")) v = `/${v}`;
@ -40,6 +51,15 @@ const apiBase = normalizeApiBase(import.meta.env.VITE_BACKEND_ORIGIN);
const timeSyncMs = envNumber("VITE_INTERVAL_TIME_SYNC_MS", 60_000);
const timeSkewMs = envNumber("VITE_INTERVAL_TIME_SKEW_MS", 5 * 60_000);
const schedulePollMs = envNumber("VITE_INTERVAL_SCHEDULE_POLL_MS", 60_000);
const firebaseConfig: FirebaseWebConfig = {
apiKey: (import.meta.env.VITE_FIREBASE_API_KEY || "").toString(),
authDomain: (import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || "").toString(),
projectId: (import.meta.env.VITE_FIREBASE_PROJECT_ID || "").toString(),
appId: (import.meta.env.VITE_FIREBASE_APP_ID || "").toString(),
};
const authEnabledEnv = (import.meta.env.VITE_AUTH_ENABLED ?? "true").toString().toLowerCase();
const authEnabled = authEnabledEnv !== "false";
const hasFirebaseConfig = firebaseConfig.apiKey && firebaseConfig.authDomain && firebaseConfig.projectId && firebaseConfig.appId;
export const config: AppConfig = {
mode: rawMode || "production",
@ -47,6 +67,10 @@ export const config: AppConfig = {
basePath,
apiBase,
backendOrigin: apiBase, // alias for clarity
auth: {
enabled: authEnabled && !!hasFirebaseConfig,
firebase: firebaseConfig,
},
intervals: {
timeSyncMs,
timeSkewMs,

View File

@ -4,6 +4,8 @@
--text: #e6eef8;
--subtle: #9fb3c8;
--accent: #79c0ff;
--success: #3dd598;
--danger: #ff6b6b;
--header-h: 56px;
}
@ -192,6 +194,66 @@ kbd {
letter-spacing: 0.2px;
text-shadow: 0 1px 10px rgba(0,0,0,0.25);
}
.auth-slot {
grid-area: banner;
display: flex;
justify-content: flex-end;
}
.auth-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
color: var(--text);
font-size: 13px;
}
.auth-chip.signed-in {
background: rgba(255,255,255,0.08);
border-color: rgba(255,255,255,0.12);
}
.auth-chip.muted { color: var(--subtle); }
.auth-avatar {
width: 32px; height: 32px; border-radius: 50%; object-fit: cover;
border: 1px solid rgba(255,255,255,0.18);
}
.auth-meta { display: grid; gap: 2px; }
.auth-name { font-weight: 700; font-size: 13px; }
.auth-subtle { color: var(--subtle); font-size: 11px; }
.auth-error { color: var(--danger); font-size: 11px; }
.auth-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.08);
color: var(--text);
cursor: pointer;
}
.auth-btn:hover { background: rgba(255,255,255,0.12); }
.auth-icon {
width: 20px; height: 20px;
display: grid; place-items: center;
background: white;
color: #4285f4;
border-radius: 6px;
font-weight: 800;
font-size: 12px;
}
.auth-signout {
margin-left: 10px;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.06);
color: var(--text);
cursor: pointer;
}
.auth-signout:hover { background: rgba(255,255,255,0.1); }
/* Sidebar (full height, left) */
.sidebar {
@ -615,4 +677,5 @@ kbd {
text-decoration: underline;
padding: 0 2px;
}
.link-btn.danger { color: var(--danger); }
.link-btn:hover { color: #bfe3ff; }

View File

@ -4,11 +4,14 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
import { config } from "./config";
import { AuthProvider } from "./auth/AuthProvider";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter basename={config.basePath}>
<App />
</BrowserRouter>
<AuthProvider>
<BrowserRouter basename={config.basePath}>
<App />
</BrowserRouter>
</AuthProvider>
</React.StrictMode>
);

View File

@ -2,8 +2,9 @@ import React, { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { logApiError } from "../utils/logger";
import { toastError, toastInfo } from "../utils/toastBus";
import { createShow, fetchDanimeEpisode, fetchShows, postCurrentEpisode } from "../api/watchparty";
import { createShow, deleteShow, fetchDanimeEpisode, fetchShows, postCurrentEpisode } from "../api/watchparty";
import type { DanimeEpisode, ShowItem } from "../api/watchparty";
import { useAuth } from "../auth/AuthProvider";
const REDIRECT_DELAY_S = 3;
@ -60,9 +61,12 @@ export default function ShowsPage() {
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redirectIn, setRedirectIn] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const redirectTid = useRef<number | null>(null);
const isRedirecting = redirectIn !== null;
const navigate = useNavigate();
const { enabled: authEnabled, idToken, backendClaims, verifying, signInWithGoogle } = useAuth();
const isAuthed = authEnabled && !!idToken;
// フォーム状態
const [selectedId, setSelectedId] = useState<number | null>(null);
@ -220,6 +224,32 @@ export default function ShowsPage() {
}
}
async function handleDelete(showId: number) {
if (!authEnabled) {
toastError("認証が無効です", "管理者に確認してください");
return;
}
if (!idToken) {
toastError("サインインしてください", "Googleでサインイン後に削除できます");
return;
}
try {
setDeletingId(showId);
await deleteShow(showId, idToken);
toastInfo("エピソードを削除しました");
if (selectedId === showId) {
setSelectedId(null);
}
await loadShows();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "削除に失敗しました。";
toastError("削除に失敗しました", msg);
logApiError("delete show", e);
} finally {
setDeletingId(null);
}
}
return (
<div className="shows-page">
<div className="scrape-card">
@ -281,9 +311,24 @@ export default function ShowsPage() {
</div>
<h2 className="h1" style={{ marginBottom: 8 }}></h2>
<p className="subtle" style={{ marginTop: 0 }}>
HH:MM
</p>
<div className="subtle" style={{ marginTop: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<span>HH:MM</span>
<span>
{!isAuthed && (
<>
{" "} <button className="link-btn" onClick={() => signInWithGoogle().catch(() => {})}>
Googleでサインイン
</button>
</>
)}
{isAuthed && (
<span style={{ color: backendClaims ? "#6de3a2" : "#f0d000" }}>
{verifying ? "トークン確認中…" : backendClaims ? "バックエンドで確認済み" : "未確認トークン"}
</span>
)}
</span>
</div>
{loading && <div className="subtle"></div>}
{error && (
@ -308,7 +353,17 @@ export default function ShowsPage() {
setStartTime(s.start_time.slice(0, 5));
}}
>
<div className="title">{s.ep_num}{s.ep_title}</div>
<div className="title-row">
<div className="title">{s.ep_num}{s.ep_title}</div>
<button
className="link-btn danger"
onClick={(e) => { e.stopPropagation(); handleDelete(s.id).catch(() => {}); }}
disabled={deletingId === s.id || verifying}
title="このエピソードを削除(認証が必要)"
>
{deletingId === s.id ? "削除中…" : "削除"}
</button>
</div>
<div className="season subtle">{s.season_name}</div>
<div className="meta subtle">
{s.start_time.slice(0, 5)} {formatPlaybackLen(s.playback_length)}