diff --git a/backend/README.md b/backend/README.md index 272812a..56fa291 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,6 +49,8 @@ Compose uses the same image for `api` and the one-off `migrate` job. - `POST /api/v1/shows` — create a new episode (expects `{ ep_num, ep_title, season_name, start_time, playback_length }`) - `GET /api/v1/danime?part_id=...` — scrape dアニメ episode metadata (returns `{ ep_num, ep_title, season_name, playback_length }`) - `POST /api/v1/oauth/firebase` — verify a Firebase ID token and echo back UID/email +- `POST /api/v1/oauth/firebase/claim-admin` — set the `admin` custom claim for a given Firebase UID +- `GET /api/v1/archive` — list archived items (auth + admin required) - `DELETE /api/v1/shows?id=...` — delete a show (guarded by Firebase auth when `AUTH_ENABLED=true`) - `GET /healthz` — health check @@ -59,6 +61,15 @@ curl -X POST http://localhost:8082/api/v1/oauth/firebase \ -H "Content-Type: application/json" \ -d '{"id_token":""}' +# Claim Firebase admin for a specific UID (AUTH_ENABLED=true) +curl -X POST http://localhost:8082/api/v1/oauth/firebase/claim-admin \ + -H "Content-Type: application/json" \ + -d '{"uid":""}' + +# List archive (admin only) +curl -X GET http://localhost:8082/api/v1/archive \ + -H "Authorization: Bearer " + # Delete a show with auth curl -X DELETE "http://localhost:8082/api/v1/shows?id=123" \ -H "Authorization: Bearer " diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 31a6b94..43e4f6a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -43,19 +43,19 @@ func main() { // 3) wiring episodeRepo := repo.NewEpisodeRepo(pool) episodeSvc := service.NewEpisodeService(episodeRepo) - var verifier auth.TokenVerifier + var authClient auth.AuthClient if cfg.Auth.Enabled { v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase) if err != nil { log.Fatalf("firebase auth init failed: %v", err) } - verifier = v + authClient = v log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID) } else { log.Printf("auth disabled (AUTH_ENABLED=false)") } - router := httpapi.NewRouter(episodeSvc, verifier, cfg.Auth.Enabled) + router := httpapi.NewRouter(episodeSvc, authClient, cfg.Auth.Enabled) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // 4) HTTP server with timeouts diff --git a/backend/docs/docs.go b/backend/docs/docs.go index df099b4..e7746e6 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -16,6 +16,39 @@ const docTemplate = `{ "basePath": "{{.BasePath}}", "paths": { "/api/v1/archive": { + "get": { + "description": "List all rows from ` + "`" + `current_archive` + "`" + ` (admin only).", + "produces": [ + "application/json" + ], + "tags": [ + "archive" + ], + "summary": "List archive", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.ArchiveResponse" + } + } + }, + "403": { + "description": "admin only", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "list failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + }, "post": { "description": "Move one or many IDs from ` + "`" + `current` + "`" + ` to ` + "`" + `current_archive` + "`" + `.", "consumes": [ @@ -236,6 +269,58 @@ const docTemplate = `{ } } }, + "/api/v1/oauth/firebase/claim-admin": { + "post": { + "description": "Set the \\\"admin\\\" custom claim for the given Firebase UID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Claim Firebase admin", + "parameters": [ + { + "description": "Firebase UID", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimRes" + } + }, + "400": { + "description": "invalid payload", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "claim failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "503": { + "description": "auth disabled", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + } + }, "/api/v1/shows": { "get": { "description": "List all rows from ` + "`" + `current` + "`" + ` table.", @@ -354,6 +439,47 @@ const docTemplate = `{ } }, "definitions": { + "httpapi.ArchiveResponse": { + "type": "object", + "properties": { + "current_ep": { + "type": "boolean", + "example": false + }, + "date_archived": { + "type": "string", + "example": "2024-03-01T15:04:05Z" + }, + "date_created": { + "type": "string", + "example": "2024-02-01T15:04:05Z" + }, + "ep_num": { + "type": "integer", + "example": 1 + }, + "ep_title": { + "type": "string", + "example": "Pilot" + }, + "id": { + "type": "integer", + "example": 123 + }, + "playback_length": { + "type": "string", + "example": "00:24:00" + }, + "season_name": { + "type": "string", + "example": "Season 1" + }, + "start_time": { + "type": "string", + "example": "10:00:00" + } + } + }, "httpapi.CreateShowReq": { "type": "object", "required": [ @@ -444,6 +570,39 @@ const docTemplate = `{ } } }, + "httpapi.FirebaseAdminClaimReq": { + "type": "object", + "required": [ + "uid" + ], + "properties": { + "uid": { + "type": "string", + "example": "abc123" + } + } + }, + "httpapi.FirebaseAdminClaimRes": { + "type": "object", + "properties": { + "admin": { + "type": "boolean", + "example": true + }, + "custom_claims": { + "type": "object", + "additionalProperties": true + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "uid": { + "type": "string", + "example": "abc123" + } + } + }, "httpapi.FirebaseOAuthReq": { "type": "object", "required": [ @@ -459,6 +618,15 @@ const docTemplate = `{ "httpapi.FirebaseOAuthRes": { "type": "object", "properties": { + "admin": { + "type": "boolean", + "example": true + }, + "custom_claims": { + "description": "CustomClaims echoes back custom claims; only include non-sensitive claims.", + "type": "object", + "additionalProperties": true + }, "email": { "type": "string", "example": "user@example.com" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 3cf335d..4cd1ca7 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -14,6 +14,39 @@ "basePath": "/", "paths": { "/api/v1/archive": { + "get": { + "description": "List all rows from `current_archive` (admin only).", + "produces": [ + "application/json" + ], + "tags": [ + "archive" + ], + "summary": "List archive", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.ArchiveResponse" + } + } + }, + "403": { + "description": "admin only", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "list failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + }, "post": { "description": "Move one or many IDs from `current` to `current_archive`.", "consumes": [ @@ -234,6 +267,58 @@ } } }, + "/api/v1/oauth/firebase/claim-admin": { + "post": { + "description": "Set the \\\"admin\\\" custom claim for the given Firebase UID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Claim Firebase admin", + "parameters": [ + { + "description": "Firebase UID", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimRes" + } + }, + "400": { + "description": "invalid payload", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "claim failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "503": { + "description": "auth disabled", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + } + }, "/api/v1/shows": { "get": { "description": "List all rows from `current` table.", @@ -352,6 +437,47 @@ } }, "definitions": { + "httpapi.ArchiveResponse": { + "type": "object", + "properties": { + "current_ep": { + "type": "boolean", + "example": false + }, + "date_archived": { + "type": "string", + "example": "2024-03-01T15:04:05Z" + }, + "date_created": { + "type": "string", + "example": "2024-02-01T15:04:05Z" + }, + "ep_num": { + "type": "integer", + "example": 1 + }, + "ep_title": { + "type": "string", + "example": "Pilot" + }, + "id": { + "type": "integer", + "example": 123 + }, + "playback_length": { + "type": "string", + "example": "00:24:00" + }, + "season_name": { + "type": "string", + "example": "Season 1" + }, + "start_time": { + "type": "string", + "example": "10:00:00" + } + } + }, "httpapi.CreateShowReq": { "type": "object", "required": [ @@ -442,6 +568,39 @@ } } }, + "httpapi.FirebaseAdminClaimReq": { + "type": "object", + "required": [ + "uid" + ], + "properties": { + "uid": { + "type": "string", + "example": "abc123" + } + } + }, + "httpapi.FirebaseAdminClaimRes": { + "type": "object", + "properties": { + "admin": { + "type": "boolean", + "example": true + }, + "custom_claims": { + "type": "object", + "additionalProperties": true + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "uid": { + "type": "string", + "example": "abc123" + } + } + }, "httpapi.FirebaseOAuthReq": { "type": "object", "required": [ @@ -457,6 +616,15 @@ "httpapi.FirebaseOAuthRes": { "type": "object", "properties": { + "admin": { + "type": "boolean", + "example": true + }, + "custom_claims": { + "description": "CustomClaims echoes back custom claims; only include non-sensitive claims.", + "type": "object", + "additionalProperties": true + }, "email": { "type": "string", "example": "user@example.com" diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 4d0508d..92e7b28 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,35 @@ basePath: / definitions: + httpapi.ArchiveResponse: + properties: + current_ep: + example: false + type: boolean + date_archived: + example: "2024-03-01T15:04:05Z" + type: string + date_created: + example: "2024-02-01T15:04:05Z" + type: string + ep_num: + example: 1 + type: integer + ep_title: + example: Pilot + type: string + id: + example: 123 + type: integer + playback_length: + example: "00:24:00" + type: string + season_name: + example: Season 1 + type: string + start_time: + example: "10:00:00" + type: string + type: object httpapi.CreateShowReq: properties: ep_num: @@ -66,6 +96,29 @@ definitions: example: フルーツバスケット 2nd season type: string type: object + httpapi.FirebaseAdminClaimReq: + properties: + uid: + example: abc123 + type: string + required: + - uid + type: object + httpapi.FirebaseAdminClaimRes: + properties: + admin: + example: true + type: boolean + custom_claims: + additionalProperties: true + type: object + email: + example: user@example.com + type: string + uid: + example: abc123 + type: string + type: object httpapi.FirebaseOAuthReq: properties: id_token: @@ -76,6 +129,14 @@ definitions: type: object httpapi.FirebaseOAuthRes: properties: + admin: + example: true + type: boolean + custom_claims: + additionalProperties: true + description: CustomClaims echoes back custom claims; only include non-sensitive + claims. + type: object email: example: user@example.com type: string @@ -146,6 +207,28 @@ info: version: "1.0" paths: /api/v1/archive: + get: + description: List all rows from `current_archive` (admin only). + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/httpapi.ArchiveResponse' + type: array + "403": + description: admin only + schema: + $ref: '#/definitions/httpapi.HTTPError' + "500": + description: list failed + schema: + $ref: '#/definitions/httpapi.HTTPError' + summary: List archive + tags: + - archive post: consumes: - application/json @@ -290,6 +373,40 @@ paths: summary: Verify Firebase ID token tags: - auth + /api/v1/oauth/firebase/claim-admin: + post: + consumes: + - application/json + description: Set the \"admin\" custom claim for the given Firebase UID. + parameters: + - description: Firebase UID + in: body + name: body + required: true + schema: + $ref: '#/definitions/httpapi.FirebaseAdminClaimReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.FirebaseAdminClaimRes' + "400": + description: invalid payload + schema: + $ref: '#/definitions/httpapi.HTTPError' + "500": + description: claim failed + schema: + $ref: '#/definitions/httpapi.HTTPError' + "503": + description: auth disabled + schema: + $ref: '#/definitions/httpapi.HTTPError' + summary: Claim Firebase admin + tags: + - auth /api/v1/shows: delete: description: Delete a row from `current` by ID. diff --git a/backend/internal/auth/firebase.go b/backend/internal/auth/firebase.go index dac2275..22a0a87 100644 --- a/backend/internal/auth/firebase.go +++ b/backend/internal/auth/firebase.go @@ -16,6 +16,12 @@ type TokenVerifier interface { Verify(ctx context.Context, token string) (*fbauth.Token, error) } +// AuthClient can verify tokens and mutate Firebase custom claims. +type AuthClient interface { + TokenVerifier + SetAdminClaim(ctx context.Context, uid string) (string, map[string]interface{}, error) +} + // FirebaseAuth verifies Firebase ID tokens. type FirebaseAuth struct { client *fbauth.Client @@ -46,3 +52,20 @@ func NewFirebaseAuth(ctx context.Context, cfg config.FirebaseConfig) (*FirebaseA func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) { return f.client.VerifyIDToken(ctx, token) } + +// SetAdminClaim sets the "admin" custom claim while preserving existing claims. +func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (string, map[string]interface{}, error) { + user, err := f.client.GetUser(ctx, uid) + if err != nil { + return "", nil, fmt.Errorf("get user: %w", err) + } + claims := make(map[string]interface{}, len(user.CustomClaims)+1) + for k, v := range user.CustomClaims { + claims[k] = v + } + claims["admin"] = true + if err := f.client.SetCustomUserClaims(ctx, uid, claims); err != nil { + return "", nil, fmt.Errorf("set custom claims: %w", err) + } + return user.Email, claims, nil +} diff --git a/backend/internal/core/episode/model.go b/backend/internal/core/episode/model.go index 9b8f280..57b3e01 100644 --- a/backend/internal/core/episode/model.go +++ b/backend/internal/core/episode/model.go @@ -26,6 +26,19 @@ type Episode struct { DateCreated time.Time `json:"date_created"` } +// ArchiveEpisode represents a row in the current_archive table. +type ArchiveEpisode struct { + Id int `json:"id"` + EpNum int `json:"ep_num"` + EpTitle string `json:"ep_title"` + SeasonName string `json:"season_name"` + StartTime string `json:"start_time"` + PlaybackLength string `json:"playback_length"` + CurrentEp bool `json:"current_ep"` + DateCreated time.Time `json:"date_created"` + DateArchived time.Time `json:"date_archived"` +} + // NewShowInput is the payload needed to create a new show/episode. type NewShowInput struct { EpNum int `json:"ep_num"` @@ -49,5 +62,6 @@ type Repository interface { Create(ctx context.Context, in NewShowInput) (Episode, error) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) ListAll(ctx context.Context) ([]Episode, error) + ListArchive(ctx context.Context) ([]ArchiveEpisode, error) Delete(ctx context.Context, id int64) error } diff --git a/backend/internal/core/episode/ports.go b/backend/internal/core/episode/ports.go index 57a5081..5b1cf71 100644 --- a/backend/internal/core/episode/ports.go +++ b/backend/internal/core/episode/ports.go @@ -9,5 +9,6 @@ type UseCases interface { Create(ctx context.Context, in NewShowInput) (Episode, error) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) ListAll(ctx context.Context) ([]Episode, error) + ListArchive(ctx context.Context) ([]ArchiveEpisode, error) Delete(ctx context.Context, id int64) error } diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index 44ba1fe..a5ddb51 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -15,7 +15,7 @@ import ( "github.com/gin-gonic/gin" ) -func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bool) *gin.Engine { +func NewRouter(svc episode.UseCases, authClient auth.AuthClient, authEnabled bool) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) @@ -29,14 +29,16 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo v1.POST("/current", setCurrentHandler(svc)) v1.POST("/shows", createShowHandler(svc)) v1.GET("/shows", listShowsHandler(svc)) - v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled)) + v1.POST("/oauth/firebase", oauthFirebaseHandler(authClient, authEnabled)) + v1.POST("/oauth/firebase/claim-admin", claimFirebaseAdminHandler(authClient, authEnabled)) v1.GET("/danime", getDanimeHandler()) - if authEnabled && verifier != nil { + if authEnabled && authClient != nil { protected := v1.Group("/") - protected.Use(AuthMiddleware(verifier)) + protected.Use(AuthMiddleware(authClient)) protected.DELETE("/shows", deleteShowsHandler(svc)) protected.POST("/archive", moveToArchiveHandler(svc)) + protected.GET("/archive", AdminOnly(), listArchiveHandler(svc)) } else { v1.DELETE("/shows", deleteShowsHandler(svc)) v1.POST("/archive", moveToArchiveHandler(svc)) @@ -333,11 +335,93 @@ func oauthFirebaseHandler(verifier auth.TokenVerifier, authEnabled bool) gin.Han return } email, _ := token.Claims["email"].(string) + admin, _ := token.Claims["admin"].(bool) c.JSON(http.StatusOK, FirebaseOAuthRes{ UID: token.UID, Email: email, Issuer: token.Issuer, Expires: token.Expires, + Admin: admin, + CustomClaims: map[string]interface{}{ + "admin": admin, + }, }) } } + +// claimFirebaseAdminHandler godoc +// @Summary Claim Firebase admin +// @Description Set the \"admin\" custom claim for the given Firebase UID. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body FirebaseAdminClaimReq true "Firebase UID" +// @Success 200 {object} FirebaseAdminClaimRes +// @Failure 400 {object} HTTPError "invalid payload" +// @Failure 503 {object} HTTPError "auth disabled" +// @Failure 500 {object} HTTPError "claim failed" +// @Router /api/v1/oauth/firebase/claim-admin [post] +func claimFirebaseAdminHandler(client auth.AuthClient, authEnabled bool) gin.HandlerFunc { + return func(c *gin.Context) { + if !authEnabled || client == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth disabled"}) + return + } + var req FirebaseAdminClaimReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + uid := strings.TrimSpace(req.UID) + if uid == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uid is required"}) + return + } + email, claims, err := client.SetAdminClaim(c.Request.Context(), uid) + if err != nil { + log.Printf("set admin claim failed for uid=%s: %v", uid, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"}) + return + } + c.JSON(http.StatusOK, FirebaseAdminClaimRes{ + UID: uid, + Email: email, + Admin: true, + CustomClaims: claims, + }) + } +} + +// listArchiveHandler godoc +// @Summary List archive +// @Description List all rows from `current_archive` (admin only). +// @Tags archive +// @Produce json +// @Success 200 {array} ArchiveResponse +// @Failure 403 {object} HTTPError "admin only" +// @Failure 500 {object} HTTPError "list failed" +// @Router /api/v1/archive [get] +func listArchiveHandler(svc episode.UseCases) gin.HandlerFunc { + return func(c *gin.Context) { + items, err := svc.ListArchive(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) + return + } + res := make([]ArchiveResponse, 0, len(items)) + for _, it := range items { + res = append(res, ArchiveResponse{ + ID: it.Id, + EpNum: it.EpNum, + EpTitle: it.EpTitle, + SeasonName: it.SeasonName, + StartTime: it.StartTime, + PlaybackLength: it.PlaybackLength, + CurrentEp: it.CurrentEp, + DateCreated: it.DateCreated, + DateArchived: it.DateArchived, + }) + } + c.JSON(http.StatusOK, res) + } +} diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index 5f277bc..7dc2171 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -10,6 +10,9 @@ import ( "testing" "time" + fbauth "firebase.google.com/go/v4/auth" + + "watch-party-backend/internal/auth" "watch-party-backend/internal/core/episode" "watch-party-backend/internal/danime" httpapi "watch-party-backend/internal/http" @@ -36,6 +39,8 @@ type fakeSvc struct { lastMove []int64 lastDelID int64 lastCreate episode.NewShowInput + archiveRes []episode.ArchiveEpisode + archiveErr error } func (f *fakeSvc) GetCurrent(ctx context.Context) (episode.Episode, error) { @@ -55,6 +60,9 @@ func (f *fakeSvc) ListAll(ctx context.Context) ([]episode.Episode, error) { } return f.listRes, f.listErr } +func (f *fakeSvc) ListArchive(ctx context.Context) ([]episode.ArchiveEpisode, error) { + return f.archiveRes, f.archiveErr +} func (f *fakeSvc) Delete(ctx context.Context, id int64) error { f.lastDelID = id return f.deleteErr @@ -67,8 +75,12 @@ func (f *fakeSvc) Create(ctx context.Context, in episode.NewShowInput) (episode. // ---- helpers ---- func newRouterWithSvc(svc episode.UseCases) *gin.Engine { + return newRouterWithOpts(svc, nil, false) +} + +func newRouterWithOpts(svc episode.UseCases, authClient auth.AuthClient, authEnabled bool) *gin.Engine { gin.SetMode(gin.TestMode) - return httpapi.NewRouter(svc, nil, false) + return httpapi.NewRouter(svc, authClient, authEnabled) } func doJSON(t *testing.T, r *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { @@ -86,6 +98,30 @@ func doJSON(t *testing.T, r *gin.Engine, method, path string, body any) *httptes return w } +type fakeAuthClient struct { + token *fbauth.Token + verifyErr error + claimErr error + claims map[string]interface{} + email string + lastUID string +} + +func (f *fakeAuthClient) Verify(_ context.Context, _ string) (*fbauth.Token, error) { + return f.token, f.verifyErr +} + +func (f *fakeAuthClient) SetAdminClaim(_ context.Context, uid string) (string, map[string]interface{}, error) { + f.lastUID = uid + if f.claimErr != nil { + return "", nil, f.claimErr + } + if f.claims != nil { + return f.email, f.claims, nil + } + return f.email, map[string]interface{}{"admin": true}, nil +} + // ---- tests ---- func TestHealthz_OK(t *testing.T) { @@ -399,6 +435,104 @@ func TestListShows_OK(t *testing.T) { } } +func TestClaimFirebaseAdmin_Disabled(t *testing.T) { + r := newRouterWithSvc(&fakeSvc{}) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"uid": "uid-x"}) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestClaimFirebaseAdmin_InvalidPayload(t *testing.T) { + client := &fakeAuthClient{} + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestClaimFirebaseAdmin_ClaimError(t *testing.T) { + client := &fakeAuthClient{ + claimErr: errors.New("cannot set"), + } + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"uid": "uid-2"}) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } + if client.lastUID != "uid-2" { + t.Fatalf("expected claim to run on uid-2, got %s", client.lastUID) + } +} + +func TestClaimFirebaseAdmin_OK(t *testing.T) { + client := &fakeAuthClient{ + email: "user@example.com", + claims: map[string]interface{}{"admin": true, "role": "owner"}, + } + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"uid": "uid-3"}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("json: %v", err) + } + if body["uid"] != "uid-3" || body["admin"] != true { + t.Fatalf("unexpected body: %+v", body) + } + claims, ok := body["custom_claims"].(map[string]any) + if !ok || claims["role"] != "owner" { + t.Fatalf("claims not returned as expected: %+v", body["custom_claims"]) + } + if client.lastUID != "uid-3" { + t.Fatalf("expected claim to run on uid-3, got %s", client.lastUID) + } + if body["email"] != "user@example.com" { + t.Fatalf("expected email propagated, got %v", body["email"]) + } +} + +func TestListArchive_AdminRequired(t *testing.T) { + svc := &fakeSvc{} + client := &fakeAuthClient{token: &fbauth.Token{UID: "uid-1", Claims: map[string]interface{}{"admin": false}}} + r := newRouterWithOpts(svc, client, true) + req := httptest.NewRequest(http.MethodGet, "/api/v1/archive", nil) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestListArchive_OK(t *testing.T) { + now := time.Now().UTC() + svc := &fakeSvc{ + archiveRes: []episode.ArchiveEpisode{ + {Id: 1, EpNum: 1, EpTitle: "Pilot", SeasonName: "S1", StartTime: "10:00:00", PlaybackLength: "00:24:00", DateCreated: now, DateArchived: now}, + }, + } + client := &fakeAuthClient{token: &fbauth.Token{UID: "uid-1", Claims: map[string]interface{}{"admin": true}}} + r := newRouterWithOpts(svc, client, true) + req := httptest.NewRequest(http.MethodGet, "/api/v1/archive", nil) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var items []episode.ArchiveEpisode + if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil { + t.Fatalf("json: %v", err) + } + if len(items) != 1 || items[0].EpTitle != "Pilot" { + t.Fatalf("unexpected items: %+v", items) + } +} + func TestListShows_Error(t *testing.T) { svc := &fakeSvc{listErr: errors.New("query failed")} r := newRouterWithSvc(svc) diff --git a/backend/internal/http/middleware.go b/backend/internal/http/middleware.go index 7084c25..351dc2b 100644 --- a/backend/internal/http/middleware.go +++ b/backend/internal/http/middleware.go @@ -6,6 +6,7 @@ import ( "watch-party-backend/internal/auth" + fbauth "firebase.google.com/go/v4/auth" "github.com/gin-gonic/gin" ) @@ -31,3 +32,25 @@ func AuthMiddleware(verifier auth.TokenVerifier) gin.HandlerFunc { c.Next() } } + +// AdminOnly ensures the firebaseToken context value has admin=true claim. +func AdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + val, ok := c.Get("firebaseToken") + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + token, ok := val.(*fbauth.Token) + if !ok || token == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + admin, _ := token.Claims["admin"].(bool) + if !admin { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"}) + return + } + c.Next() + } +} diff --git a/backend/internal/http/middleware_test.go b/backend/internal/http/middleware_test.go index 530a1e1..e2adf43 100644 --- a/backend/internal/http/middleware_test.go +++ b/backend/internal/http/middleware_test.go @@ -69,3 +69,54 @@ func TestAuthMiddleware_Success(t *testing.T) { t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) } } + +func TestAdminOnly_MissingToken(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(AdminOnly()) + r.GET("/", func(c *gin.Context) {}) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestAdminOnly_Forbidden(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("firebaseToken", &fbauth.Token{Claims: map[string]interface{}{"admin": false}}) + }) + r.Use(AdminOnly()) + r.GET("/", func(c *gin.Context) {}) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected status %d, got %d", http.StatusForbidden, w.Code) + } +} + +func TestAdminOnly_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("firebaseToken", &fbauth.Token{Claims: map[string]interface{}{"admin": true}}) + }) + r.Use(AdminOnly()) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) + } +} diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 35fa5f8..7be3979 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -69,4 +69,33 @@ type FirebaseOAuthRes struct { Email string `json:"email,omitempty" example:"user@example.com"` Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/"` Expires int64 `json:"expires,omitempty" example:"1700000000"` + Admin bool `json:"admin,omitempty" example:"true"` + // CustomClaims echoes back custom claims; only include non-sensitive claims. + CustomClaims map[string]interface{} `json:"custom_claims,omitempty"` +} + +// FirebaseAdminClaimReq is the request body for POST /api/v1/oauth/firebase/claim-admin. +type FirebaseAdminClaimReq struct { + UID string `json:"uid" binding:"required" example:"abc123"` +} + +// FirebaseAdminClaimRes is the response body for POST /api/v1/oauth/firebase/claim-admin. +type FirebaseAdminClaimRes struct { + UID string `json:"uid" example:"abc123"` + Email string `json:"email,omitempty" example:"user@example.com"` + Admin bool `json:"admin" example:"true"` + CustomClaims map[string]interface{} `json:"custom_claims,omitempty"` +} + +// ArchiveResponse represents a row from current_archive. +type ArchiveResponse struct { + ID int `json:"id" example:"123"` + EpNum int `json:"ep_num" example:"1"` + EpTitle string `json:"ep_title" example:"Pilot"` + SeasonName string `json:"season_name" example:"Season 1"` + StartTime string `json:"start_time" example:"10:00:00"` + PlaybackLength string `json:"playback_length" example:"00:24:00"` + CurrentEp bool `json:"current_ep" example:"false"` + DateCreated time.Time `json:"date_created" example:"2024-02-01T15:04:05Z"` + DateArchived time.Time `json:"date_archived" example:"2024-03-01T15:04:05Z"` } diff --git a/backend/internal/repo/episode_repo.go b/backend/internal/repo/episode_repo.go index 92ae8e1..57d0bda 100644 --- a/backend/internal/repo/episode_repo.go +++ b/backend/internal/repo/episode_repo.go @@ -112,6 +112,38 @@ RETURNING return e, nil } +func (r *pgxEpisodeRepo) ListArchive(ctx context.Context) ([]episode.ArchiveEpisode, error) { + const q = ` +SELECT + id, + ep_num, + ep_title, + season_name, + to_char(start_time, 'HH24:MI:SS') AS start_time, + to_char(playback_length, 'HH24:MI:SS') AS playback_length, + current_ep, + date_created, + date_archived +FROM current_archive +ORDER BY date_archived DESC, id DESC; +` + rows, err := r.pool.Query(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []episode.ArchiveEpisode + for rows.Next() { + var e episode.ArchiveEpisode + if err := rows.Scan(&e.Id, &e.EpNum, &e.EpTitle, &e.SeasonName, &e.StartTime, &e.PlaybackLength, &e.CurrentEp, &e.DateCreated, &e.DateArchived); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + func (r *pgxEpisodeRepo) SetCurrent(ctx context.Context, id int64, startHHMMSS string) error { tx, err := r.pool.Begin(ctx) if err != nil { diff --git a/backend/internal/service/episode_service.go b/backend/internal/service/episode_service.go index c542a42..4299db9 100644 --- a/backend/internal/service/episode_service.go +++ b/backend/internal/service/episode_service.go @@ -24,6 +24,12 @@ func (s *Service) ListAll(ctx context.Context) ([]episode.Episode, error) { return s.repo.ListAll(c) } +func (s *Service) ListArchive(ctx context.Context) ([]episode.ArchiveEpisode, error) { + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return s.repo.ListArchive(c) +} + func (s *Service) GetCurrent(ctx context.Context) (episode.Episode, error) { c, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() diff --git a/backend/internal/service/episode_service_test.go b/backend/internal/service/episode_service_test.go index cf7344e..fe5920b 100644 --- a/backend/internal/service/episode_service_test.go +++ b/backend/internal/service/episode_service_test.go @@ -22,12 +22,14 @@ type fakeRepo struct { id int64 start string } - moveCalls [][]int64 - listRes []episode.Episode - listErr error - createRes episode.Episode - createErr error - createIn []episode.NewShowInput + moveCalls [][]int64 + listRes []episode.Episode + listErr error + createRes episode.Episode + createErr error + createIn []episode.NewShowInput + archiveRes []episode.ArchiveEpisode + archiveErr error } func (f *fakeRepo) GetCurrent(ctx context.Context) (episode.Episode, error) { @@ -47,6 +49,9 @@ func (f *fakeRepo) MoveToArchive(ctx context.Context, ids []int64) (episode.Move func (f *fakeRepo) ListAll(ctx context.Context) ([]episode.Episode, error) { return f.listRes, f.listErr } +func (f *fakeRepo) ListArchive(ctx context.Context) ([]episode.ArchiveEpisode, error) { + return f.archiveRes, f.archiveErr +} func (f *fakeRepo) Delete(ctx context.Context, id int64) error { return nil } @@ -177,6 +182,28 @@ func TestEpisodeService_ListAll_Error(t *testing.T) { } } +func TestEpisodeService_ListArchive(t *testing.T) { + fr := &fakeRepo{ + archiveRes: []episode.ArchiveEpisode{{Id: 1}, {Id: 2}}, + } + svc := service.NewEpisodeService(fr) + got, err := svc.ListArchive(context.Background()) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + if len(got) != 2 || got[0].Id != 1 { + t.Fatalf("bad archive list: %+v", got) + } +} + +func TestEpisodeService_ListArchive_Error(t *testing.T) { + fr := &fakeRepo{archiveErr: errors.New("down")} + svc := service.NewEpisodeService(fr) + if _, err := svc.ListArchive(context.Background()); err == nil { + t.Fatal("expected error") + } +} + func TestEpisodeService_Create_Validation(t *testing.T) { tests := []struct { name string diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d51986..e29ce7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,10 +2,12 @@ import React from "react"; import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom"; import Timer from "./components/Timer"; import ShowsPage from "./pages/ShowsPage"; +import ArchivePage from "./pages/ArchivePage"; import TimeSyncNotice from "./components/TimeSyncNotice"; import { ToastViewport } from "./components/Toasts"; import DebugOverlay from "./components/DebugOverlay"; import AuthStatus from "./components/AuthStatus"; +import { useAuth } from "./auth/AuthProvider"; import "./index.css"; const TIME_SYNC_OFF_THRESHOLD = 100; @@ -13,6 +15,7 @@ const TIME_SYNC_OFF_THRESHOLD = 100; export default function App() { const [open, setOpen] = React.useState(false); const loc = useLocation(); + const { isAdmin } = useAuth(); // close sidebar on route change React.useEffect(() => setOpen(false), [loc.pathname]); @@ -46,6 +49,7 @@ export default function App() {
管理用サインイン
@@ -68,6 +72,7 @@ export default function App() { } /> } /> + } />
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b8c26cc..26b062b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -34,6 +34,8 @@ export type FirebaseAuthResponse = { email?: string; issuer?: string; expires?: number; + admin?: boolean; + custom_claims?: Record; }; export type ArchiveResult = { @@ -44,3 +46,15 @@ export type ArchiveResult = { deleted: number; skipped: number; }; + +export type ArchiveItem = { + id: number; + ep_num: number; + ep_title: string; + season_name: string; + start_time: string; + playback_length: string; + current_ep: boolean; + date_created: string; + date_archived: string; +}; diff --git a/frontend/src/api/watchparty.ts b/frontend/src/api/watchparty.ts index 1011bdf..a3de934 100644 --- a/frontend/src/api/watchparty.ts +++ b/frontend/src/api/watchparty.ts @@ -1,8 +1,8 @@ import { API_ENDPOINT } from "./endpoint"; import { ApiError, apiFetch } from "./client"; -import type { ArchiveResult, DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types"; +import type { ArchiveItem, ArchiveResult, DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types"; -export type { DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types"; +export type { DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse, ArchiveItem } from "./types"; function asNumber(v: unknown, fallback = 0) { const n = typeof v === "number" ? v : Number(v); @@ -52,6 +52,33 @@ function normalizeShows(data: unknown): ShowItem[] { }); } +function normalizeArchive(data: unknown): ArchiveItem[] { + if (!Array.isArray(data)) { + throw new ApiError("Archive payload is not an array", { url: API_ENDPOINT.v1.ARCHIVE, data }); + } + return data.map((item) => { + if (!item || typeof item !== "object") { + throw new ApiError("Bad archive item", { url: API_ENDPOINT.v1.ARCHIVE, data: item }); + } + const obj = item as Record; + const id = asNumber(obj.id, NaN); + if (!Number.isFinite(id)) { + throw new ApiError("Archive item missing id", { url: API_ENDPOINT.v1.ARCHIVE, data: item }); + } + return { + id, + ep_num: asNumber(obj.ep_num, 0), + ep_title: asString(obj.ep_title, "不明"), + season_name: asString(obj.season_name, "不明"), + start_time: asString(obj.start_time, ""), + playback_length: asString(obj.playback_length, ""), + current_ep: Boolean(obj.current_ep), + date_created: asString(obj.date_created, ""), + date_archived: asString(obj.date_archived, ""), + }; + }); +} + function normalizeDanimeEpisode(data: unknown): DanimeEpisode { if (!data || typeof data !== "object") { throw new ApiError("Bad danime payload", { url: API_ENDPOINT.v1.DANIME, data }); @@ -153,3 +180,18 @@ export async function archiveShow(id: number, idToken: string) { logLabel: "archive show", }); } + +export async function fetchArchive(idToken: string) { + if (!idToken) { + throw new ApiError("Missing auth token for archive list"); + } + const data = await apiFetch(API_ENDPOINT.v1.ARCHIVE, { + method: "GET", + headers: { + "Authorization": `Bearer ${idToken}`, + }, + timeoutMs: 12_000, + logLabel: "fetch archive", + }); + return normalizeArchive(data); +} diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index a244155..78208a4 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -13,6 +13,7 @@ type AuthContextShape = { user: User | null; idToken: string | null; backendClaims: FirebaseAuthResponse | null; + isAdmin: boolean; verifying: boolean; signingIn: boolean; error: string | null; @@ -29,6 +30,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = React.useState(null); const [idToken, setIdToken] = React.useState(null); const [backendClaims, setBackendClaims] = React.useState(null); + const isAdmin = Boolean(backendClaims?.admin ?? backendClaims?.custom_claims?.admin); const [verifying, setVerifying] = React.useState(false); const [signingIn, setSigningIn] = React.useState(false); const [error, setError] = React.useState(null); @@ -132,13 +134,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { user, idToken, backendClaims, + isAdmin, verifying, signingIn, error, signInWithGoogle, signOut, refreshToken, - }), [enabled, status, user, idToken, backendClaims, verifying, signingIn, error, signInWithGoogle, signOut, refreshToken]); + }), [enabled, status, user, idToken, backendClaims, isAdmin, verifying, signingIn, error, signInWithGoogle, signOut, refreshToken]); return ( diff --git a/frontend/src/components/AuthStatus.tsx b/frontend/src/components/AuthStatus.tsx index 1ea9857..68b8a19 100644 --- a/frontend/src/components/AuthStatus.tsx +++ b/frontend/src/components/AuthStatus.tsx @@ -1,7 +1,7 @@ import { useAuth } from "../auth/AuthProvider"; export default function AuthStatus() { - const { enabled, status, user, verifying, signingIn, signInWithGoogle, signOut, error } = useAuth(); + const { enabled, status, user, verifying, signingIn, signInWithGoogle, signOut, error, isAdmin } = useAuth(); if (!enabled) { return
Auth off
; @@ -24,7 +24,10 @@ export default function AuthStatus() {
{user.photoURL && }
-
{user.displayName || user.email || user.uid}
+
+ {user.displayName || user.email || user.uid} + {isAdmin && ADMIN} +
{verifying && (
確認中… diff --git a/frontend/src/index.css b/frontend/src/index.css index 20b2512..f5b7997 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -70,7 +70,7 @@ html, body, #root { border-radius: 16px; padding: 28px 32px; box-shadow: 0 10px 30px rgba(0,0,0,0.25); - max-width: 720px; + max-width: 1280px; width: 100%; text-align: center; container-type: inline-size; @@ -503,6 +503,62 @@ kbd { background: var(--accent); color:#0b0f14; border:none; } .primary:disabled { opacity:0.5; cursor:not-allowed; } +.archive-table { + display: grid; + gap: 6px; + text-align: left; +} +.archive-header, +.archive-row { + display: grid; + grid-template-columns: 70px 80px 1fr 1fr 90px 90px 150px 150px; + gap: 10px; + align-items: center; + padding: 10px 12px; + border-radius: 10px; +} +.archive-scroll { + width: 100%; + overflow: auto; +} +.archive-table { + min-width: 1040px; +} +.archive-header { + font-weight: 800; + color: var(--subtle); + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); +} +.archive-head-btn { + all: unset; + cursor: pointer; + font-weight: 800; + color: var(--subtle); + display: inline-flex; + align-items: center; + gap: 4px; +} +.archive-head-btn:hover { color: var(--text); } +.archive-row { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.05); +} +.archive-row:hover { background: rgba(255,255,255,0.05); } +.archive-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (max-width: 820px) { + .archive-header, + .archive-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + row-gap: 6px; + } + .archive-header span:nth-child(3), + .archive-row span:nth-child(3) { grid-column: span 2; } + .archive-header span:nth-child(4), + .archive-row span:nth-child(4) { grid-column: span 2; } + .archive-table { min-width: 100%; } +} + .scrape-card { padding: 14px; border-radius: 14px; diff --git a/frontend/src/pages/ArchivePage.tsx b/frontend/src/pages/ArchivePage.tsx new file mode 100644 index 0000000..79c8732 --- /dev/null +++ b/frontend/src/pages/ArchivePage.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { fetchArchive } from "../api/watchparty"; +import type { ArchiveItem } from "../api/types"; +import { useAuth } from "../auth/AuthProvider"; +import { toastError } from "../utils/toastBus"; +import { logApiError } from "../utils/logger"; + +type SortKey = "id" | "ep_num" | "ep_title" | "season_name" | "start_time" | "playback_length" | "date_created" | "date_archived"; +type SortDir = "asc" | "desc" | null; + +function formatTimestamp(ts: string) { + if (!ts) return ""; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + return d.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }); +} + +export default function ArchivePage() { + const { enabled, idToken, isAdmin } = useAuth(); + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [sort, setSort] = React.useState<{ key: SortKey | null; dir: SortDir }>({ key: null, dir: null }); + + const load = React.useCallback(async () => { + if (!idToken) { + setError("サインインが必要です"); + return; + } + setError(null); + try { + setLoading(true); + const data = await fetchArchive(idToken); + setItems(data); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "アーカイブを取得できませんでした。"; + setError(msg); + logApiError("fetch archive", e); + toastError("アーカイブを取得できませんでした", msg); + } finally { + setLoading(false); + } + }, [idToken]); + + React.useEffect(() => { + if (enabled && isAdmin && idToken) { + load().catch(() => { }); + } else { + setLoading(false); + } + }, [enabled, isAdmin, idToken, load]); + + const sortedItems = React.useMemo(() => { + const { key, dir } = sort; + if (!key || !dir) return items; + const next = [...items]; + next.sort((a, b) => { + let av: string | number = ""; + let bv: string | number = ""; + switch (key) { + case "id": + case "ep_num": + av = a[key]; + bv = b[key]; + break; + case "date_created": + case "date_archived": + av = new Date(a[key]).getTime(); + bv = new Date(b[key]).getTime(); + break; + default: + av = (a as Record)[key] as string | number | undefined ?? ""; + bv = (b as Record)[key] as string | number | undefined ?? ""; + } + if (av === bv) return 0; + const asc = av > bv ? 1 : -1; + return dir === "asc" ? asc : -asc; + }); + return next; + }, [items, sort]); + + function toggleSort(key: SortKey) { + setSort((prev) => { + if (prev.key !== key) return { key, dir: "asc" }; + if (prev.dir === "asc") return { key, dir: "desc" }; + if (prev.dir === "desc") return { key: null, dir: null }; + return { key, dir: "asc" }; + }); + } + + const sortIndicator = (key: SortKey) => { + if (sort.key !== key) return ""; + if (sort.dir === "asc") return "▲"; + if (sort.dir === "desc") return "▼"; + return ""; + }; + + if (!enabled) { + return
認証が無効です。
; + } + if (!isAdmin) { + return
管理者のみアクセスできます。
; + } + + return ( +
+

アーカイブ一覧

+
+ 削除済みのエピソードを表示します。最新のアーカイブが上に表示されます。 + +
+ + {loading &&
読み込み中…
} + {error && ( +
+ {error} +
+ )} + {!loading && !error && items.length === 0 && ( +
アーカイブは空です。
+ )} + + {items.length > 0 && ( +
+
+
+ + + + + + + + +
+ {sortedItems.map((it) => ( +
+ #{it.id} + 第{it.ep_num}話 + {it.ep_title} + {it.season_name} + {it.start_time.slice(0, 5)} + {it.playback_length.slice(0, 5)} + {formatTimestamp(it.date_created)} + {formatTimestamp(it.date_archived)} +
+ ))} +
+
+ )} +
+ ); +}