From 4b5a8c1d46545c009b17b76458012ae7c73bf1f5 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 17 Dec 2025 22:21:04 +0900 Subject: [PATCH 1/7] feat(auth): add endpoint to set Firebase admin custom claim --- backend/README.md | 6 ++ backend/cmd/server/main.go | 6 +- backend/docs/docs.go | 91 +++++++++++++++++++++ backend/docs/swagger.json | 91 +++++++++++++++++++++ backend/docs/swagger.yaml | 62 +++++++++++++++ backend/internal/auth/firebase.go | 23 ++++++ backend/internal/http/handlers.go | 59 +++++++++++++- backend/internal/http/handlers_test.go | 105 ++++++++++++++++++++++++- backend/internal/http/types.go | 13 +++ 9 files changed, 448 insertions(+), 8 deletions(-) diff --git a/backend/README.md b/backend/README.md index 272812a..63a7083 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,6 +49,7 @@ 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 the UID represented by the provided ID token - `DELETE /api/v1/shows?id=...` — delete a show (guarded by Firebase auth when `AUTH_ENABLED=true`) - `GET /healthz` — health check @@ -59,6 +60,11 @@ curl -X POST http://localhost:8082/api/v1/oauth/firebase \ -H "Content-Type: application/json" \ -d '{"id_token":""}' +# Claim Firebase admin for the current token's UID (AUTH_ENABLED=true) +curl -X POST http://localhost:8082/api/v1/oauth/firebase/claim-admin \ + -H "Content-Type: application/json" \ + -d '{"id_token":""}' + # 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..4fddb42 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -236,6 +236,64 @@ const docTemplate = `{ } } }, + "/api/v1/oauth/firebase/claim-admin": { + "post": { + "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Claim Firebase admin", + "parameters": [ + { + "description": "Firebase ID token", + "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" + } + }, + "401": { + "description": "invalid token", + "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.", @@ -444,6 +502,39 @@ const docTemplate = `{ } } }, + "httpapi.FirebaseAdminClaimReq": { + "type": "object", + "required": [ + "id_token" + ], + "properties": { + "id_token": { + "type": "string", + "example": "\u003cfirebase-id-token\u003e" + } + } + }, + "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": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 3cf335d..a16d9f1 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -234,6 +234,64 @@ } } }, + "/api/v1/oauth/firebase/claim-admin": { + "post": { + "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Claim Firebase admin", + "parameters": [ + { + "description": "Firebase ID token", + "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" + } + }, + "401": { + "description": "invalid token", + "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.", @@ -442,6 +500,39 @@ } } }, + "httpapi.FirebaseAdminClaimReq": { + "type": "object", + "required": [ + "id_token" + ], + "properties": { + "id_token": { + "type": "string", + "example": "\u003cfirebase-id-token\u003e" + } + } + }, + "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": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 4d0508d..c7abd10 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -66,6 +66,29 @@ definitions: example: フルーツバスケット 2nd season type: string type: object + httpapi.FirebaseAdminClaimReq: + properties: + id_token: + example: + type: string + required: + - id_token + 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: @@ -290,6 +313,45 @@ 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 user represented by the + ID token. + parameters: + - description: Firebase ID token + 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' + "401": + description: invalid token + 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..e272bc0 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) (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) (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 claims, nil +} diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index 44ba1fe..6b84a06 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,12 +29,13 @@ 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)) } else { @@ -341,3 +342,53 @@ func oauthFirebaseHandler(verifier auth.TokenVerifier, authEnabled bool) gin.Han }) } } + +// claimFirebaseAdminHandler godoc +// @Summary Claim Firebase admin +// @Description Set the \"admin\" custom claim for the user represented by the ID token. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body FirebaseAdminClaimReq true "Firebase ID token" +// @Success 200 {object} FirebaseAdminClaimRes +// @Failure 400 {object} HTTPError "invalid payload" +// @Failure 401 {object} HTTPError "invalid token" +// @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 + } + idToken := strings.TrimSpace(req.IDToken) + if idToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id_token is required"}) + return + } + token, err := client.Verify(c.Request.Context(), idToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + claims, err := client.SetAdminClaim(c.Request.Context(), token.UID) + if err != nil { + log.Printf("set admin claim failed for uid=%s: %v", token.UID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"}) + return + } + email, _ := token.Claims["email"].(string) + c.JSON(http.StatusOK, FirebaseAdminClaimRes{ + UID: token.UID, + Email: email, + Admin: true, + CustomClaims: claims, + }) + } +} diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index 5f277bc..13b3967 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" @@ -67,8 +70,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 +93,29 @@ 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{} + 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) (map[string]interface{}, error) { + f.lastUID = uid + if f.claimErr != nil { + return nil, f.claimErr + } + if f.claims != nil { + return f.claims, nil + } + return map[string]interface{}{"admin": true}, nil +} + // ---- tests ---- func TestHealthz_OK(t *testing.T) { @@ -399,6 +429,79 @@ 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{"id_token": "tok"}) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestClaimFirebaseAdmin_InvalidPayload(t *testing.T) { + client := &fakeAuthClient{token: &fbauth.Token{UID: "uid-1"}} + 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_InvalidToken(t *testing.T) { + client := &fakeAuthClient{verifyErr: errors.New("bad token")} + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"id_token": "tok"}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestClaimFirebaseAdmin_ClaimError(t *testing.T) { + client := &fakeAuthClient{ + token: &fbauth.Token{UID: "uid-2"}, + 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{"id_token": "tok"}) + 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{ + token: &fbauth.Token{ + UID: "uid-3", + Claims: map[string]interface{}{"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{"id_token": "tok"}) + 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 TestListShows_Error(t *testing.T) { svc := &fakeSvc{listErr: errors.New("query failed")} r := newRouterWithSvc(svc) diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 35fa5f8..76c8782 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -70,3 +70,16 @@ type FirebaseOAuthRes struct { Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/"` Expires int64 `json:"expires,omitempty" example:"1700000000"` } + +// FirebaseAdminClaimReq is the request body for POST /api/v1/oauth/firebase/claim-admin. +type FirebaseAdminClaimReq struct { + IDToken string `json:"id_token" binding:"required" example:""` +} + +// 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"` +} From 204c71d7b5e5194aef16113d457ad553e7977dcb Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 17 Dec 2025 22:48:21 +0900 Subject: [PATCH 2/7] feat(auth): update claim-admin endpoint to use Firebase UID instead of ID token --- backend/README.md | 6 ++--- backend/docs/docs.go | 16 ++++--------- backend/docs/swagger.json | 16 ++++--------- backend/docs/swagger.yaml | 15 ++++-------- backend/internal/auth/firebase.go | 10 ++++---- backend/internal/http/handlers.go | 23 +++++++----------- backend/internal/http/handlers_test.go | 32 ++++++++------------------ backend/internal/http/types.go | 2 +- 8 files changed, 42 insertions(+), 78 deletions(-) diff --git a/backend/README.md b/backend/README.md index 63a7083..df1da95 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,7 +49,7 @@ 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 the UID represented by the provided ID token +- `POST /api/v1/oauth/firebase/claim-admin` — set the `admin` custom claim for a given Firebase UID - `DELETE /api/v1/shows?id=...` — delete a show (guarded by Firebase auth when `AUTH_ENABLED=true`) - `GET /healthz` — health check @@ -60,10 +60,10 @@ curl -X POST http://localhost:8082/api/v1/oauth/firebase \ -H "Content-Type: application/json" \ -d '{"id_token":""}' -# Claim Firebase admin for the current token's UID (AUTH_ENABLED=true) +# 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 '{"id_token":""}' + -d '{"uid":""}' # Delete a show with auth curl -X DELETE "http://localhost:8082/api/v1/shows?id=123" \ diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 4fddb42..0c47286 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -238,7 +238,7 @@ const docTemplate = `{ }, "/api/v1/oauth/firebase/claim-admin": { "post": { - "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "description": "Set the \\\"admin\\\" custom claim for the given Firebase UID.", "consumes": [ "application/json" ], @@ -251,7 +251,7 @@ const docTemplate = `{ "summary": "Claim Firebase admin", "parameters": [ { - "description": "Firebase ID token", + "description": "Firebase UID", "name": "body", "in": "body", "required": true, @@ -273,12 +273,6 @@ const docTemplate = `{ "$ref": "#/definitions/httpapi.HTTPError" } }, - "401": { - "description": "invalid token", - "schema": { - "$ref": "#/definitions/httpapi.HTTPError" - } - }, "500": { "description": "claim failed", "schema": { @@ -505,12 +499,12 @@ const docTemplate = `{ "httpapi.FirebaseAdminClaimReq": { "type": "object", "required": [ - "id_token" + "uid" ], "properties": { - "id_token": { + "uid": { "type": "string", - "example": "\u003cfirebase-id-token\u003e" + "example": "abc123" } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index a16d9f1..6ebab89 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -236,7 +236,7 @@ }, "/api/v1/oauth/firebase/claim-admin": { "post": { - "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "description": "Set the \\\"admin\\\" custom claim for the given Firebase UID.", "consumes": [ "application/json" ], @@ -249,7 +249,7 @@ "summary": "Claim Firebase admin", "parameters": [ { - "description": "Firebase ID token", + "description": "Firebase UID", "name": "body", "in": "body", "required": true, @@ -271,12 +271,6 @@ "$ref": "#/definitions/httpapi.HTTPError" } }, - "401": { - "description": "invalid token", - "schema": { - "$ref": "#/definitions/httpapi.HTTPError" - } - }, "500": { "description": "claim failed", "schema": { @@ -503,12 +497,12 @@ "httpapi.FirebaseAdminClaimReq": { "type": "object", "required": [ - "id_token" + "uid" ], "properties": { - "id_token": { + "uid": { "type": "string", - "example": "\u003cfirebase-id-token\u003e" + "example": "abc123" } } }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index c7abd10..5edd67a 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -68,11 +68,11 @@ definitions: type: object httpapi.FirebaseAdminClaimReq: properties: - id_token: - example: + uid: + example: abc123 type: string required: - - id_token + - uid type: object httpapi.FirebaseAdminClaimRes: properties: @@ -317,10 +317,9 @@ paths: post: consumes: - application/json - description: Set the \"admin\" custom claim for the user represented by the - ID token. + description: Set the \"admin\" custom claim for the given Firebase UID. parameters: - - description: Firebase ID token + - description: Firebase UID in: body name: body required: true @@ -337,10 +336,6 @@ paths: description: invalid payload schema: $ref: '#/definitions/httpapi.HTTPError' - "401": - description: invalid token - schema: - $ref: '#/definitions/httpapi.HTTPError' "500": description: claim failed schema: diff --git a/backend/internal/auth/firebase.go b/backend/internal/auth/firebase.go index e272bc0..22a0a87 100644 --- a/backend/internal/auth/firebase.go +++ b/backend/internal/auth/firebase.go @@ -19,7 +19,7 @@ type TokenVerifier interface { // AuthClient can verify tokens and mutate Firebase custom claims. type AuthClient interface { TokenVerifier - SetAdminClaim(ctx context.Context, uid string) (map[string]interface{}, error) + SetAdminClaim(ctx context.Context, uid string) (string, map[string]interface{}, error) } // FirebaseAuth verifies Firebase ID tokens. @@ -54,10 +54,10 @@ func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, } // SetAdminClaim sets the "admin" custom claim while preserving existing claims. -func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (map[string]interface{}, error) { +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) + return "", nil, fmt.Errorf("get user: %w", err) } claims := make(map[string]interface{}, len(user.CustomClaims)+1) for k, v := range user.CustomClaims { @@ -65,7 +65,7 @@ func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (map[strin } claims["admin"] = true if err := f.client.SetCustomUserClaims(ctx, uid, claims); err != nil { - return nil, fmt.Errorf("set custom claims: %w", err) + return "", nil, fmt.Errorf("set custom claims: %w", err) } - return claims, nil + return user.Email, claims, nil } diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index 6b84a06..fff0137 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -345,14 +345,13 @@ func oauthFirebaseHandler(verifier auth.TokenVerifier, authEnabled bool) gin.Han // claimFirebaseAdminHandler godoc // @Summary Claim Firebase admin -// @Description Set the \"admin\" custom claim for the user represented by the ID token. +// @Description Set the \"admin\" custom claim for the given Firebase UID. // @Tags auth // @Accept json // @Produce json -// @Param body body FirebaseAdminClaimReq true "Firebase ID token" +// @Param body body FirebaseAdminClaimReq true "Firebase UID" // @Success 200 {object} FirebaseAdminClaimRes // @Failure 400 {object} HTTPError "invalid payload" -// @Failure 401 {object} HTTPError "invalid token" // @Failure 503 {object} HTTPError "auth disabled" // @Failure 500 {object} HTTPError "claim failed" // @Router /api/v1/oauth/firebase/claim-admin [post] @@ -367,25 +366,19 @@ func claimFirebaseAdminHandler(client auth.AuthClient, authEnabled bool) gin.Han c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - idToken := strings.TrimSpace(req.IDToken) - if idToken == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "id_token is required"}) + uid := strings.TrimSpace(req.UID) + if uid == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uid is required"}) return } - token, err := client.Verify(c.Request.Context(), idToken) + email, claims, err := client.SetAdminClaim(c.Request.Context(), uid) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - claims, err := client.SetAdminClaim(c.Request.Context(), token.UID) - if err != nil { - log.Printf("set admin claim failed for uid=%s: %v", token.UID, err) + log.Printf("set admin claim failed for uid=%s: %v", uid, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"}) return } - email, _ := token.Claims["email"].(string) c.JSON(http.StatusOK, FirebaseAdminClaimRes{ - UID: token.UID, + UID: uid, Email: email, Admin: true, CustomClaims: claims, diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index 13b3967..dbec791 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -98,6 +98,7 @@ type fakeAuthClient struct { verifyErr error claimErr error claims map[string]interface{} + email string lastUID string } @@ -105,15 +106,15 @@ func (f *fakeAuthClient) Verify(_ context.Context, _ string) (*fbauth.Token, err return f.token, f.verifyErr } -func (f *fakeAuthClient) SetAdminClaim(_ context.Context, uid string) (map[string]interface{}, error) { +func (f *fakeAuthClient) SetAdminClaim(_ context.Context, uid string) (string, map[string]interface{}, error) { f.lastUID = uid if f.claimErr != nil { - return nil, f.claimErr + return "", nil, f.claimErr } if f.claims != nil { - return f.claims, nil + return f.email, f.claims, nil } - return map[string]interface{}{"admin": true}, nil + return f.email, map[string]interface{}{"admin": true}, nil } // ---- tests ---- @@ -431,14 +432,14 @@ 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{"id_token": "tok"}) + 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{token: &fbauth.Token{UID: "uid-1"}} + 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 { @@ -446,22 +447,12 @@ func TestClaimFirebaseAdmin_InvalidPayload(t *testing.T) { } } -func TestClaimFirebaseAdmin_InvalidToken(t *testing.T) { - client := &fakeAuthClient{verifyErr: errors.New("bad token")} - r := newRouterWithOpts(&fakeSvc{}, client, true) - w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"id_token": "tok"}) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", w.Code) - } -} - func TestClaimFirebaseAdmin_ClaimError(t *testing.T) { client := &fakeAuthClient{ - token: &fbauth.Token{UID: "uid-2"}, 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{"id_token": "tok"}) + 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) } @@ -472,14 +463,11 @@ func TestClaimFirebaseAdmin_ClaimError(t *testing.T) { func TestClaimFirebaseAdmin_OK(t *testing.T) { client := &fakeAuthClient{ - token: &fbauth.Token{ - UID: "uid-3", - Claims: map[string]interface{}{"email": "user@example.com"}, - }, + 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{"id_token": "tok"}) + 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) } diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 76c8782..c90a944 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -73,7 +73,7 @@ type FirebaseOAuthRes struct { // FirebaseAdminClaimReq is the request body for POST /api/v1/oauth/firebase/claim-admin. type FirebaseAdminClaimReq struct { - IDToken string `json:"id_token" binding:"required" example:""` + UID string `json:"uid" binding:"required" example:"abc123"` } // FirebaseAdminClaimRes is the response body for POST /api/v1/oauth/firebase/claim-admin. From 17f60c9ccb4daa4768f0201ea97fab0910b8c4bc Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 17 Dec 2025 23:05:53 +0900 Subject: [PATCH 3/7] feat(archive): add admin-only endpoint to list archived episodes and implement corresponding frontend page --- backend/README.md | 5 + backend/internal/core/episode/model.go | 14 +++ backend/internal/core/episode/ports.go | 1 + backend/internal/http/handlers.go | 40 +++++++ backend/internal/http/handlers_test.go | 43 ++++++++ backend/internal/http/middleware.go | 23 ++++ backend/internal/http/middleware_test.go | 51 +++++++++ backend/internal/http/types.go | 16 +++ backend/internal/repo/episode_repo.go | 32 ++++++ backend/internal/service/episode_service.go | 6 ++ .../internal/service/episode_service_test.go | 39 +++++-- frontend/src/App.tsx | 5 + frontend/src/api/types.ts | 14 +++ frontend/src/api/watchparty.ts | 46 +++++++- frontend/src/auth/AuthProvider.tsx | 5 +- frontend/src/components/AuthStatus.tsx | 7 +- frontend/src/index.css | 36 +++++++ frontend/src/pages/ArchivePage.tsx | 102 ++++++++++++++++++ 18 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/ArchivePage.tsx diff --git a/backend/README.md b/backend/README.md index df1da95..56fa291 100644 --- a/backend/README.md +++ b/backend/README.md @@ -50,6 +50,7 @@ Compose uses the same image for `api` and the one-off `migrate` job. - `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 @@ -65,6 +66,10 @@ 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/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 fff0137..a5ddb51 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -38,6 +38,7 @@ func NewRouter(svc episode.UseCases, authClient auth.AuthClient, authEnabled boo 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)) @@ -334,11 +335,16 @@ 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, + }, }) } } @@ -385,3 +391,37 @@ func claimFirebaseAdminHandler(client auth.AuthClient, authEnabled bool) gin.Han }) } } + +// 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 dbec791..7dc2171 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -39,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) { @@ -58,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 @@ -490,6 +495,44 @@ func TestClaimFirebaseAdmin_OK(t *testing.T) { } } +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 c90a944..7be3979 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -69,6 +69,9 @@ 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. @@ -83,3 +86,16 @@ type FirebaseAdminClaimRes struct { 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..14c8d5a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -503,6 +503,42 @@ 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 90px 90px 150px 150px; + gap: 10px; + align-items: center; + padding: 10px 12px; + border-radius: 10px; +} +.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-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; } +} + .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..f960a15 --- /dev/null +++ b/frontend/src/pages/ArchivePage.tsx @@ -0,0 +1,102 @@ +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"; + +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 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]); + + if (!enabled) { + return
認証が無効です。
; + } + if (!isAdmin) { + return
管理者のみアクセスできます。
; + } + + return ( +
+

アーカイブ一覧

+
+ 削除済みのエピソードを表示します。最新のアーカイブが上に表示されます。 + +
+ + {loading &&
読み込み中…
} + {error && ( +
+ {error} +
+ )} + {!loading && !error && items.length === 0 && ( +
アーカイブは空です。
+ )} + + {items.length > 0 && ( +
+
+ ID + 話数 + タイトル + 開始時刻 + 再生時間 + 登録 + アーカイブ +
+ {items.map((it) => ( +
+ #{it.id} + 第{it.ep_num}話 + {it.ep_title} + {it.start_time.slice(0, 5)} + {it.playback_length.slice(0, 5)} + {formatTimestamp(it.date_created)} + {formatTimestamp(it.date_archived)} +
+ ))} +
+ )} +
+ ); +} From 77c0c1586b13fc26cddd24297e693305b7c03de1 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 17 Dec 2025 23:07:08 +0900 Subject: [PATCH 4/7] feat(docs): add admin-only GET endpoint to list archived episodes with Swagger documentation --- backend/docs/docs.go | 83 +++++++++++++++++++++++++++++++++++++++ backend/docs/swagger.json | 83 +++++++++++++++++++++++++++++++++++++++ backend/docs/swagger.yaml | 60 ++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 0c47286..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": [ @@ -406,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": [ @@ -544,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 6ebab89..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": [ @@ -404,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": [ @@ -542,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 5edd67a..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: @@ -99,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 @@ -169,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 From f17905eeff2f99b09c0afe4d5516cc7114e04278 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 17 Dec 2025 23:13:40 +0900 Subject: [PATCH 5/7] feat(archive): enhance archive page with scrollable table and responsive design --- frontend/src/index.css | 8 ++++++ frontend/src/pages/ArchivePage.tsx | 42 ++++++++++++++++-------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 14c8d5a..2b3fff3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -517,6 +517,13 @@ kbd { padding: 10px 12px; border-radius: 10px; } +.archive-scroll { + width: 100%; + overflow: auto; +} +.archive-table { + min-width: 860px; +} .archive-header { font-weight: 800; color: var(--subtle); @@ -537,6 +544,7 @@ kbd { } .archive-header span:nth-child(3), .archive-row span:nth-child(3) { grid-column: span 2; } + .archive-table { min-width: 100%; } } .scrape-card { diff --git a/frontend/src/pages/ArchivePage.tsx b/frontend/src/pages/ArchivePage.tsx index f960a15..0db2f50 100644 --- a/frontend/src/pages/ArchivePage.tsx +++ b/frontend/src/pages/ArchivePage.tsx @@ -74,27 +74,29 @@ export default function ArchivePage() { )} {items.length > 0 && ( -
-
- ID - 話数 - タイトル - 開始時刻 - 再生時間 - 登録 - アーカイブ -
- {items.map((it) => ( -
- #{it.id} - 第{it.ep_num}話 - {it.ep_title} - {it.start_time.slice(0, 5)} - {it.playback_length.slice(0, 5)} - {formatTimestamp(it.date_created)} - {formatTimestamp(it.date_archived)} +
+
+
+ ID + 話数 + タイトル + 開始時刻 + 再生時間 + 登録 + アーカイブ
- ))} + {items.map((it) => ( +
+ #{it.id} + 第{it.ep_num}話 + {it.ep_title} + {it.start_time.slice(0, 5)} + {it.playback_length.slice(0, 5)} + {formatTimestamp(it.date_created)} + {formatTimestamp(it.date_archived)} +
+ ))} +
)}
From 2889a38ab68b152c4504c3b3d403b5ad0a172e54 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 17 Dec 2025 23:42:40 +0900 Subject: [PATCH 6/7] feat(archive): update archive page layout and add season column to episode list --- frontend/src/index.css | 8 +++++--- frontend/src/pages/ArchivePage.tsx | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 2b3fff3..704a8da 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; @@ -511,7 +511,7 @@ kbd { .archive-header, .archive-row { display: grid; - grid-template-columns: 70px 80px 1fr 90px 90px 150px 150px; + grid-template-columns: 70px 80px 1fr 1fr 90px 90px 150px 150px; gap: 10px; align-items: center; padding: 10px 12px; @@ -522,7 +522,7 @@ kbd { overflow: auto; } .archive-table { - min-width: 860px; + min-width: 1040px; } .archive-header { font-weight: 800; @@ -544,6 +544,8 @@ kbd { } .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%; } } diff --git a/frontend/src/pages/ArchivePage.tsx b/frontend/src/pages/ArchivePage.tsx index 0db2f50..4ccdcf9 100644 --- a/frontend/src/pages/ArchivePage.tsx +++ b/frontend/src/pages/ArchivePage.tsx @@ -80,6 +80,7 @@ export default function ArchivePage() { ID 話数 タイトル + シーズン 開始時刻 再生時間 登録 @@ -90,6 +91,7 @@ export default function ArchivePage() { #{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)} From 5b02eb12264b373e5275558f7281a5f195faf87e Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 18 Dec 2025 01:01:35 +0900 Subject: [PATCH 7/7] feat(archive): implement sortable columns in archive table with toggle functionality --- frontend/src/index.css | 10 ++++ frontend/src/pages/ArchivePage.tsx | 83 ++++++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 704a8da..f5b7997 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -530,6 +530,16 @@ kbd { 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); diff --git a/frontend/src/pages/ArchivePage.tsx b/frontend/src/pages/ArchivePage.tsx index 4ccdcf9..79c8732 100644 --- a/frontend/src/pages/ArchivePage.tsx +++ b/frontend/src/pages/ArchivePage.tsx @@ -5,6 +5,9 @@ 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); @@ -17,6 +20,7 @@ export default function ArchivePage() { 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) { @@ -46,6 +50,51 @@ export default function ArchivePage() { } }, [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
認証が無効です。
; } @@ -77,16 +126,32 @@ export default function ArchivePage() {
- ID - 話数 - タイトル - シーズン - 開始時刻 - 再生時間 - 登録 - アーカイブ + + + + + + + +
- {items.map((it) => ( + {sortedItems.map((it) => (
#{it.id} 第{it.ep_num}話