diff --git a/cmd/main.go b/cmd/main.go index 35bcc38..b8ee732 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -414,7 +414,7 @@ func main() { ) // Team management service - teamSvc := team.NewService(repository.NewTeamStore(store)) + teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry) // santimpayClient := santimpay.NewSantimPayClient(cfg) diff --git a/db/migrations/000039_team_refresh_tokens.down.sql b/db/migrations/000039_team_refresh_tokens.down.sql new file mode 100644 index 0000000..a01ac55 --- /dev/null +++ b/db/migrations/000039_team_refresh_tokens.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_team_refresh_tokens_team_member_id; +DROP TABLE IF EXISTS team_refresh_tokens; diff --git a/db/migrations/000039_team_refresh_tokens.up.sql b/db/migrations/000039_team_refresh_tokens.up.sql new file mode 100644 index 0000000..c511c87 --- /dev/null +++ b/db/migrations/000039_team_refresh_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS team_refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + team_member_id BIGINT NOT NULL REFERENCES team_members(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_team_refresh_tokens_team_member_id + ON team_refresh_tokens (team_member_id); diff --git a/db/query/team_refresh_tokens.sql b/db/query/team_refresh_tokens.sql new file mode 100644 index 0000000..7978358 --- /dev/null +++ b/db/query/team_refresh_tokens.sql @@ -0,0 +1,19 @@ +-- name: RevokeAllActiveTeamRefreshTokensForMember :exec +UPDATE team_refresh_tokens +SET revoked = TRUE +WHERE team_member_id = $1 + AND revoked = FALSE; + +-- name: CreateTeamRefreshToken :exec +INSERT INTO team_refresh_tokens (team_member_id, token, expires_at, revoked, created_at) +VALUES ($1, $2, $3, $4, $5); + +-- name: GetTeamRefreshTokenByToken :one +SELECT * +FROM team_refresh_tokens +WHERE token = $1; + +-- name: RevokeTeamRefreshTokenByToken :exec +UPDATE team_refresh_tokens +SET revoked = TRUE +WHERE token = $1; diff --git a/docs/docs.go b/docs/docs.go index a6b8cec..9784f41 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -7682,6 +7682,70 @@ const docTemplate = `{ } } }, + "/api/v1/team/refresh": { + "post": { + "description": "Exchanges a valid team refresh token for a new access JWT and a rotated refresh token (use only tokens from POST /team/login or this endpoint).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Refresh team member tokens", + "parameters": [ + { + "description": "Current refresh token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.teamMemberRefreshReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.teamMemberLoginRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/team/stats": { "get": { "security": [ @@ -11609,7 +11673,7 @@ const docTemplate = `{ }, "refresh_token": { "type": "string", - "example": "" + "example": "\u003copaque-refresh-token\u003e" }, "team_role": { "type": "string", @@ -11617,6 +11681,17 @@ const docTemplate = `{ } } }, + "handlers.teamMemberRefreshReq": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, "handlers.updateAdminReq": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index acd641e..0450e43 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -7674,6 +7674,70 @@ } } }, + "/api/v1/team/refresh": { + "post": { + "description": "Exchanges a valid team refresh token for a new access JWT and a rotated refresh token (use only tokens from POST /team/login or this endpoint).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "team" + ], + "summary": "Refresh team member tokens", + "parameters": [ + { + "description": "Current refresh token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.teamMemberRefreshReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handlers.teamMemberLoginRes" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/team/stats": { "get": { "security": [ @@ -11601,7 +11665,7 @@ }, "refresh_token": { "type": "string", - "example": "" + "example": "\u003copaque-refresh-token\u003e" }, "team_role": { "type": "string", @@ -11609,6 +11673,17 @@ } } }, + "handlers.teamMemberRefreshReq": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, "handlers.updateAdminReq": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8338477..b052347 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1448,12 +1448,19 @@ definitions: example: 1 type: integer refresh_token: - example: "" + example: type: string team_role: example: admin type: string type: object + handlers.teamMemberRefreshReq: + properties: + refresh_token: + type: string + required: + - refresh_token + type: object handlers.updateAdminReq: properties: first_name: @@ -7117,6 +7124,46 @@ paths: summary: Update team member status tags: - team + /api/v1/team/refresh: + post: + consumes: + - application/json + description: Exchanges a valid team refresh token for a new access JWT and a + rotated refresh token (use only tokens from POST /team/login or this endpoint). + parameters: + - description: Current refresh token + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.teamMemberRefreshReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + $ref: '#/definitions/handlers.teamMemberLoginRes' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Refresh team member tokens + tags: + - team /api/v1/team/stats: get: consumes: diff --git a/gen/db/models.go b/gen/db/models.go index ff48e2c..f21f980 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -477,6 +477,15 @@ type TeamMember struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type TeamRefreshToken struct { + ID int64 `json:"id"` + TeamMemberID int64 `json:"team_member_id"` + Token string `json:"token"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Revoked bool `json:"revoked"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID int64 `json:"id"` FirstName pgtype.Text `json:"first_name"` diff --git a/gen/db/team_refresh_tokens.sql.go b/gen/db/team_refresh_tokens.sql.go new file mode 100644 index 0000000..260d92c --- /dev/null +++ b/gen/db/team_refresh_tokens.sql.go @@ -0,0 +1,79 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: team_refresh_tokens.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateTeamRefreshToken = `-- name: CreateTeamRefreshToken :exec +INSERT INTO team_refresh_tokens (team_member_id, token, expires_at, revoked, created_at) +VALUES ($1, $2, $3, $4, $5) +` + +type CreateTeamRefreshTokenParams struct { + TeamMemberID int64 `json:"team_member_id"` + Token string `json:"token"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Revoked bool `json:"revoked"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) CreateTeamRefreshToken(ctx context.Context, arg CreateTeamRefreshTokenParams) error { + _, err := q.db.Exec(ctx, CreateTeamRefreshToken, + arg.TeamMemberID, + arg.Token, + arg.ExpiresAt, + arg.Revoked, + arg.CreatedAt, + ) + return err +} + +const GetTeamRefreshTokenByToken = `-- name: GetTeamRefreshTokenByToken :one +SELECT id, team_member_id, token, expires_at, revoked, created_at +FROM team_refresh_tokens +WHERE token = $1 +` + +func (q *Queries) GetTeamRefreshTokenByToken(ctx context.Context, token string) (TeamRefreshToken, error) { + row := q.db.QueryRow(ctx, GetTeamRefreshTokenByToken, token) + var i TeamRefreshToken + err := row.Scan( + &i.ID, + &i.TeamMemberID, + &i.Token, + &i.ExpiresAt, + &i.Revoked, + &i.CreatedAt, + ) + return i, err +} + +const RevokeAllActiveTeamRefreshTokensForMember = `-- name: RevokeAllActiveTeamRefreshTokensForMember :exec +UPDATE team_refresh_tokens +SET revoked = TRUE +WHERE team_member_id = $1 + AND revoked = FALSE +` + +func (q *Queries) RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, teamMemberID int64) error { + _, err := q.db.Exec(ctx, RevokeAllActiveTeamRefreshTokensForMember, teamMemberID) + return err +} + +const RevokeTeamRefreshTokenByToken = `-- name: RevokeTeamRefreshTokenByToken :exec +UPDATE team_refresh_tokens +SET revoked = TRUE +WHERE token = $1 +` + +func (q *Queries) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, RevokeTeamRefreshTokenByToken, token) + return err +} diff --git a/internal/domain/team.go b/internal/domain/team.go index 36b5451..06bab9e 100644 --- a/internal/domain/team.go +++ b/internal/domain/team.go @@ -6,12 +6,14 @@ import ( ) var ( - ErrTeamMemberNotFound = errors.New("team member not found") - ErrTeamMemberEmailExists = errors.New("team member email already exists") - ErrInvalidTeamRole = errors.New("invalid team role") - ErrInvalidTeamMemberStatus = errors.New("invalid team member status") - ErrInvalidEmploymentType = errors.New("invalid employment type") + ErrTeamMemberNotFound = errors.New("team member not found") + ErrTeamMemberEmailExists = errors.New("team member email already exists") + ErrInvalidTeamRole = errors.New("invalid team role") + ErrInvalidTeamMemberStatus = errors.New("invalid team member status") + ErrInvalidEmploymentType = errors.New("invalid employment type") ErrTeamMemberEmailNotVerified = errors.New("team member email not verified") + ErrTeamRefreshTokenNotFound = errors.New("team refresh token not found") + ErrTeamRefreshTokenExpired = errors.New("team refresh token expired") ) type TeamRole string @@ -80,6 +82,16 @@ func (e EmploymentType) IsValid() bool { } } +// TeamRefreshToken is a persisted refresh token for team member sessions (separate from users.refresh_tokens). +type TeamRefreshToken struct { + ID int64 + TeamMemberID int64 + Token string + ExpiresAt time.Time + Revoked bool + CreatedAt time.Time +} + type TeamMember struct { ID int64 FirstName string diff --git a/internal/ports/team.go b/internal/ports/team.go index bf58732..7d352eb 100644 --- a/internal/ports/team.go +++ b/internal/ports/team.go @@ -2,6 +2,7 @@ package ports import ( "context" + "time" "Yimaru-Backend/internal/domain" ) @@ -30,4 +31,9 @@ type TeamStore interface { GetTeamMembersByRole(ctx context.Context, role string) ([]domain.TeamMember, error) CountTeamMembersByStatus(ctx context.Context) (domain.TeamMemberStats, error) UpdateTeamMemberEmailVerified(ctx context.Context, memberID int64, verified bool) error + + RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, memberID int64) error + CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error + GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error) + RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error } diff --git a/internal/repository/team.go b/internal/repository/team.go index 4a71c78..9317fb0 100644 --- a/internal/repository/team.go +++ b/internal/repository/team.go @@ -455,3 +455,42 @@ func mapGetTeamMembersByRoleRow(row dbgen.GetTeamMembersByRoleRow) domain.TeamMe func parseDate(dateStr string) (time.Time, error) { return time.Parse("2006-01-02", dateStr) } + +func (s *Store) RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, memberID int64) error { + return s.queries.RevokeAllActiveTeamRefreshTokensForMember(ctx, memberID) +} + +func (s *Store) CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error { + return s.queries.CreateTeamRefreshToken(ctx, dbgen.CreateTeamRefreshTokenParams{ + TeamMemberID: memberID, + Token: token, + ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, + Revoked: false, + CreatedAt: pgtype.Timestamptz{Time: createdAt, Valid: true}, + }) +} + +func (s *Store) GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error) { + row, err := s.queries.GetTeamRefreshTokenByToken(ctx, token) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TeamRefreshToken{}, domain.ErrTeamRefreshTokenNotFound + } + return domain.TeamRefreshToken{}, err + } + if !row.ExpiresAt.Valid || !row.CreatedAt.Valid { + return domain.TeamRefreshToken{}, domain.ErrTeamRefreshTokenNotFound + } + return domain.TeamRefreshToken{ + ID: row.ID, + TeamMemberID: row.TeamMemberID, + Token: row.Token, + ExpiresAt: row.ExpiresAt.Time, + Revoked: row.Revoked, + CreatedAt: row.CreatedAt.Time, + }, nil +} + +func (s *Store) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error { + return s.queries.RevokeTeamRefreshTokenByToken(ctx, token) +} diff --git a/internal/services/team/service.go b/internal/services/team/service.go index 29d4706..76031b4 100644 --- a/internal/services/team/service.go +++ b/internal/services/team/service.go @@ -5,11 +5,16 @@ import ( ) type Service struct { - teamStore ports.TeamStore + teamStore ports.TeamStore + refreshExpirySec int } -func NewService(teamStore ports.TeamStore) *Service { +func NewService(teamStore ports.TeamStore, refreshExpirySeconds int) *Service { + if refreshExpirySeconds <= 0 { + refreshExpirySeconds = 7 * 24 * 3600 + } return &Service{ - teamStore: teamStore, + teamStore: teamStore, + refreshExpirySec: refreshExpirySeconds, } } diff --git a/internal/services/team/tokens.go b/internal/services/team/tokens.go new file mode 100644 index 0000000..b43a0b8 --- /dev/null +++ b/internal/services/team/tokens.go @@ -0,0 +1,69 @@ +package team + +import ( + "context" + "crypto/rand" + "encoding/base32" + "time" + + "Yimaru-Backend/internal/domain" +) + +func generateOpaqueRefreshToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil +} + +// IssueRefreshTokenOnLogin revokes prior active team refresh tokens for the member and stores a new one. +func (s *Service) IssueRefreshTokenOnLogin(ctx context.Context, memberID int64) (string, error) { + if err := s.teamStore.RevokeAllActiveTeamRefreshTokensForMember(ctx, memberID); err != nil { + return "", err + } + tok, err := generateOpaqueRefreshToken() + if err != nil { + return "", err + } + now := time.Now() + exp := now.Add(time.Duration(s.refreshExpirySec) * time.Second) + if err := s.teamStore.CreateTeamRefreshToken(ctx, memberID, tok, exp, now); err != nil { + return "", err + } + return tok, nil +} + +// RefreshSession validates a team refresh token, rotates it, and returns the member plus the new refresh token plaintext. +func (s *Service) RefreshSession(ctx context.Context, refreshTokenPlain string) (domain.TeamMember, string, error) { + rt, err := s.teamStore.GetTeamRefreshTokenByToken(ctx, refreshTokenPlain) + if err != nil { + return domain.TeamMember{}, "", err + } + if rt.Revoked { + return domain.TeamMember{}, "", domain.ErrTeamRefreshTokenNotFound + } + if time.Now().After(rt.ExpiresAt) { + return domain.TeamMember{}, "", domain.ErrTeamRefreshTokenExpired + } + if err := s.teamStore.RevokeTeamRefreshTokenByToken(ctx, refreshTokenPlain); err != nil { + return domain.TeamMember{}, "", err + } + newTok, err := generateOpaqueRefreshToken() + if err != nil { + return domain.TeamMember{}, "", err + } + now := time.Now() + exp := now.Add(time.Duration(s.refreshExpirySec) * time.Second) + if err := s.teamStore.CreateTeamRefreshToken(ctx, rt.TeamMemberID, newTok, exp, now); err != nil { + return domain.TeamMember{}, "", err + } + member, err := s.teamStore.GetTeamMemberByID(ctx, rt.TeamMemberID) + if err != nil { + return domain.TeamMember{}, "", err + } + if member.Status != domain.TeamMemberStatusActive { + return domain.TeamMember{}, "", domain.ErrInvalidTeamMemberStatus + } + return member, newTok, nil +} diff --git a/internal/web_server/handlers/team_handler.go b/internal/web_server/handlers/team_handler.go index b61e6a4..590768f 100644 --- a/internal/web_server/handlers/team_handler.go +++ b/internal/web_server/handlers/team_handler.go @@ -17,11 +17,15 @@ import ( // teamMemberLoginRes represents the response body for team member login type teamMemberLoginRes struct { AccessToken string `json:"access_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` - RefreshToken string `json:"refresh_token" example:""` + RefreshToken string `json:"refresh_token" example:""` MemberID int64 `json:"member_id" example:"1"` TeamRole string `json:"team_role" example:"admin"` } +type teamMemberRefreshReq struct { + RefreshToken string `json:"refresh_token" validate:"required"` +} + // changePasswordReq represents the request body for changing password type changePasswordReq struct { CurrentPassword string `json:"current_password" validate:"required" example:"oldpassword123"` @@ -139,6 +143,20 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error { }) } + refreshTokenStr, err := h.teamSvc.IssueRefreshTokenOnLogin(c.Context(), loginRes.ID) + if err != nil { + h.mongoLoggerSvc.Error("Failed to issue team refresh token", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("member_id", loginRes.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to login", + Error: "Failed to issue refresh token", + }) + } + h.mongoLoggerSvc.Info("Team member login successful", zap.Int("status_code", fiber.StatusOK), zap.Int64("member_id", loginRes.ID), @@ -150,7 +168,7 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error { Message: "Login successful", Data: teamMemberLoginRes{ AccessToken: accessToken, - RefreshToken: "", + RefreshToken: refreshTokenStr, MemberID: loginRes.ID, TeamRole: string(loginRes.TeamRole), }, @@ -159,6 +177,81 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error { }) } +// TeamMemberRefresh godoc +// @Summary Refresh team member tokens +// @Description Exchanges a valid team refresh token for a new access JWT and a rotated refresh token (use only tokens from POST /team/login or this endpoint). +// @Tags team +// @Accept json +// @Produce json +// @Param body body teamMemberRefreshReq true "Current refresh token" +// @Success 200 {object} domain.Response{data=teamMemberLoginRes} +// @Failure 400 {object} domain.ErrorResponse +// @Failure 401 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/team/refresh [post] +func (h *Handler) TeamMemberRefresh(c *fiber.Ctx) error { + var req teamMemberRefreshReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Validation failed", + Error: errMsg, + }) + } + + member, newRefresh, err := h.teamSvc.RefreshSession(c.Context(), req.RefreshToken) + if err != nil { + switch { + case errors.Is(err, domain.ErrTeamRefreshTokenNotFound), errors.Is(err, domain.ErrTeamRefreshTokenExpired): + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Refresh failed", + Error: "Invalid or expired refresh token", + }) + case errors.Is(err, domain.ErrInvalidTeamMemberStatus): + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Refresh failed", + Error: "Account is not active", + }) + default: + h.mongoLoggerSvc.Error("Team refresh failed", zap.Error(err), zap.Time("timestamp", time.Now())) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Refresh failed", + Error: err.Error(), + }) + } + } + + role := mapTeamRoleToRole(member.TeamRole) + accessToken, err := jwtutil.CreateJwt(member.ID, role, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Refresh failed", + Error: "Failed to generate access token", + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Token refreshed successfully", + Data: teamMemberLoginRes{ + AccessToken: accessToken, + RefreshToken: newRefresh, + MemberID: member.ID, + TeamRole: string(member.TeamRole), + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + // CreateTeamMember godoc // @Summary Create a new team member // @Description Create a new internal team member (admin only) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 02c6577..d356122 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -330,6 +330,7 @@ func (a *App) initAppRoutes() { // Team Management teamGroup := groupV1.Group("/team") teamGroup.Post("/login", h.TeamMemberLogin) + teamGroup.Post("/refresh", h.TeamMemberRefresh) teamGroup.Get("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile) teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats) teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers)