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:
parent
8b268640a5
commit
008f8a3cca
@ -26,6 +26,14 @@ ADDR=:8082
|
|||||||
# Auth (optional)
|
# Auth (optional)
|
||||||
AUTH_ENABLED=false
|
AUTH_ENABLED=false
|
||||||
FIREBASE_PROJECT_ID=
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_CREDENTIALS_FILE=/secrets/firebase_credentials.json
|
||||||
# Use one of the following to supply Firebase service account:
|
# Use one of the following to supply Firebase service account:
|
||||||
# FIREBASE_CREDENTIALS_JSON=
|
# FIREBASE_CREDENTIALS_JSON=
|
||||||
# FIREBASE_CREDENTIALS_FILE=/path/to/firebase.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=
|
||||||
|
|||||||
996
frontend/package-lock.json
generated
996
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"firebase": "^12.6.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.5"
|
"react-router-dom": "^7.9.5"
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import ShowsPage from "./pages/ShowsPage";
|
|||||||
import TimeSyncNotice from "./components/TimeSyncNotice";
|
import TimeSyncNotice from "./components/TimeSyncNotice";
|
||||||
import { ToastViewport } from "./components/Toasts";
|
import { ToastViewport } from "./components/Toasts";
|
||||||
import DebugOverlay from "./components/DebugOverlay";
|
import DebugOverlay from "./components/DebugOverlay";
|
||||||
|
import AuthStatus from "./components/AuthStatus";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const TIME_SYNC_OFF_THRESHOLD = 100;
|
const TIME_SYNC_OFF_THRESHOLD = 100;
|
||||||
@ -36,6 +37,9 @@ export default function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<Link to="/" className="brand">Watch Party</Link>
|
<Link to="/" className="brand">Watch Party</Link>
|
||||||
|
<div className="auth-slot">
|
||||||
|
<AuthStatus />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/* Time sync banner (checks every 5 min; shows if |skew| > 500ms) */}
|
{/* Time sync banner (checks every 5 min; shows if |skew| > 500ms) */}
|
||||||
<TimeSyncNotice thresholdMs={TIME_SYNC_OFF_THRESHOLD} lang="ja" />
|
<TimeSyncNotice thresholdMs={TIME_SYNC_OFF_THRESHOLD} lang="ja" />
|
||||||
|
|||||||
13
frontend/src/api/auth.ts
Normal file
13
frontend/src/api/auth.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -6,5 +6,6 @@ export const API_ENDPOINT = {
|
|||||||
SHOWS: buildApiUrl("/v1/shows"),
|
SHOWS: buildApiUrl("/v1/shows"),
|
||||||
TIME: buildApiUrl("/v1/time"),
|
TIME: buildApiUrl("/v1/time"),
|
||||||
DANIME: buildApiUrl("/v1/danime"),
|
DANIME: buildApiUrl("/v1/danime"),
|
||||||
|
OAUTH_FIREBASE: buildApiUrl("/v1/oauth/firebase"),
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -28,3 +28,10 @@ export type DanimeEpisode = {
|
|||||||
season_name: string;
|
season_name: string;
|
||||||
playback_length: string;
|
playback_length: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FirebaseAuthResponse = {
|
||||||
|
uid: string;
|
||||||
|
email?: string;
|
||||||
|
issuer?: string;
|
||||||
|
expires?: number;
|
||||||
|
};
|
||||||
|
|||||||
@ -136,3 +136,19 @@ export async function createShow(payload: {
|
|||||||
logLabel: "create show",
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
126
frontend/src/auth/AuthProvider.tsx
Normal file
126
frontend/src/auth/AuthProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
17
frontend/src/auth/firebaseClient.ts
Normal file
17
frontend/src/auth/firebaseClient.ts
Normal 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;
|
||||||
|
}
|
||||||
33
frontend/src/components/AuthStatus.tsx
Normal file
33
frontend/src/components/AuthStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,10 @@ type AppConfig = {
|
|||||||
basePath: string;
|
basePath: string;
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
backendOrigin: string;
|
backendOrigin: string;
|
||||||
|
auth: {
|
||||||
|
enabled: boolean;
|
||||||
|
firebase: FirebaseWebConfig;
|
||||||
|
};
|
||||||
intervals: {
|
intervals: {
|
||||||
timeSyncMs: number;
|
timeSyncMs: number;
|
||||||
timeSkewMs: number;
|
timeSkewMs: number;
|
||||||
@ -11,6 +15,13 @@ type AppConfig = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FirebaseWebConfig = {
|
||||||
|
apiKey: string;
|
||||||
|
authDomain: string;
|
||||||
|
projectId: string;
|
||||||
|
appId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function normalizeBasePath(raw?: string) {
|
export function normalizeBasePath(raw?: string) {
|
||||||
let v = (raw || "/").trim();
|
let v = (raw || "/").trim();
|
||||||
if (!v.startsWith("/")) v = `/${v}`;
|
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 timeSyncMs = envNumber("VITE_INTERVAL_TIME_SYNC_MS", 60_000);
|
||||||
const timeSkewMs = envNumber("VITE_INTERVAL_TIME_SKEW_MS", 5 * 60_000);
|
const timeSkewMs = envNumber("VITE_INTERVAL_TIME_SKEW_MS", 5 * 60_000);
|
||||||
const schedulePollMs = envNumber("VITE_INTERVAL_SCHEDULE_POLL_MS", 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 = {
|
export const config: AppConfig = {
|
||||||
mode: rawMode || "production",
|
mode: rawMode || "production",
|
||||||
@ -47,6 +67,10 @@ export const config: AppConfig = {
|
|||||||
basePath,
|
basePath,
|
||||||
apiBase,
|
apiBase,
|
||||||
backendOrigin: apiBase, // alias for clarity
|
backendOrigin: apiBase, // alias for clarity
|
||||||
|
auth: {
|
||||||
|
enabled: authEnabled && !!hasFirebaseConfig,
|
||||||
|
firebase: firebaseConfig,
|
||||||
|
},
|
||||||
intervals: {
|
intervals: {
|
||||||
timeSyncMs,
|
timeSyncMs,
|
||||||
timeSkewMs,
|
timeSkewMs,
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
--text: #e6eef8;
|
--text: #e6eef8;
|
||||||
--subtle: #9fb3c8;
|
--subtle: #9fb3c8;
|
||||||
--accent: #79c0ff;
|
--accent: #79c0ff;
|
||||||
|
--success: #3dd598;
|
||||||
|
--danger: #ff6b6b;
|
||||||
--header-h: 56px;
|
--header-h: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,6 +194,66 @@ kbd {
|
|||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
text-shadow: 0 1px 10px rgba(0,0,0,0.25);
|
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 (full height, left) */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@ -615,4 +677,5 @@ kbd {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
.link-btn.danger { color: var(--danger); }
|
||||||
.link-btn:hover { color: #bfe3ff; }
|
.link-btn:hover { color: #bfe3ff; }
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
<BrowserRouter basename={config.basePath}>
|
<BrowserRouter basename={config.basePath}>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import React, { useEffect, useMemo, useState, useRef } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { logApiError } from "../utils/logger";
|
import { logApiError } from "../utils/logger";
|
||||||
import { toastError, toastInfo } from "../utils/toastBus";
|
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 type { DanimeEpisode, ShowItem } from "../api/watchparty";
|
||||||
|
import { useAuth } from "../auth/AuthProvider";
|
||||||
|
|
||||||
const REDIRECT_DELAY_S = 3;
|
const REDIRECT_DELAY_S = 3;
|
||||||
|
|
||||||
@ -60,9 +61,12 @@ export default function ShowsPage() {
|
|||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [redirectIn, setRedirectIn] = useState<number | null>(null);
|
const [redirectIn, setRedirectIn] = useState<number | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
const redirectTid = useRef<number | null>(null);
|
const redirectTid = useRef<number | null>(null);
|
||||||
const isRedirecting = redirectIn !== null;
|
const isRedirecting = redirectIn !== null;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { enabled: authEnabled, idToken, backendClaims, verifying, signInWithGoogle } = useAuth();
|
||||||
|
const isAuthed = authEnabled && !!idToken;
|
||||||
|
|
||||||
// フォーム状態
|
// フォーム状態
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
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 (
|
return (
|
||||||
<div className="shows-page">
|
<div className="shows-page">
|
||||||
<div className="scrape-card">
|
<div className="scrape-card">
|
||||||
@ -281,9 +311,24 @@ export default function ShowsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="h1" style={{ marginBottom: 8 }}>エピソード一覧</h2>
|
<h2 className="h1" style={{ marginBottom: 8 }}>エピソード一覧</h2>
|
||||||
<p className="subtle" style={{ marginTop: 0 }}>
|
<div className="subtle" style={{ marginTop: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
エピソードを選び、必要に応じて開始時刻(HH:MM)を入力し、「現在のエピソードに設定」を押してください。
|
<span>エピソードを選び、必要に応じて開始時刻(HH:MM)を入力し、「現在のエピソードに設定」を押してください。</span>
|
||||||
</p>
|
<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>}
|
{loading && <div className="subtle">読み込み中…</div>}
|
||||||
{error && (
|
{error && (
|
||||||
@ -308,7 +353,17 @@ export default function ShowsPage() {
|
|||||||
setStartTime(s.start_time.slice(0, 5));
|
setStartTime(s.start_time.slice(0, 5));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="title-row">
|
||||||
<div className="title">第{s.ep_num}話:{s.ep_title}</div>
|
<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="season subtle">{s.season_name}</div>
|
||||||
<div className="meta subtle">
|
<div className="meta subtle">
|
||||||
開始時刻 {s.start_time.slice(0, 5)}・再生時間 {formatPlaybackLen(s.playback_length)}
|
開始時刻 {s.start_time.slice(0, 5)}・再生時間 {formatPlaybackLen(s.playback_length)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user