Restrict platform-list edits to admins
Anyone authenticated can still read the shared platform list, but only an
admin may add, rename or delete from it.
- users.is_admin boolean, defaulting to false. The very first account ever
registered becomes an admin automatically (CreateUser's INSERT uses
NOT EXISTS (SELECT 1 FROM users) for is_admin), so there's always at
least one without manual setup; promoting anyone beyond that is a manual
UPDATE for now (documented in the migration's comment on the column).
- GetSessionAndUser, GetUserByEmail and GetUserByID now also select
is_admin, and it flows through to model.User (and so to
/api/auth/{register,login,me}'s response) and to the auth middleware's
authedUser.
- New requireAdminMiddleware + addAdminAuth, layered on top of the existing
auth middleware. Wired onto create/update/delete-platform; list/get stay
on plain addAuth, since reading the list only requires being logged in.
Verified end to end: the first registered user comes back is_admin:true,
the second false; the non-admin gets 403 on create/update/delete-platform
but 200 on list; the admin can do all of it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fbd403d30
commit
384c4a7217
12 files changed
+102
-27
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated
|
||||
{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated
|
||||
@@ -14,6 +14,13 @@ CREATE TABLE users (
|
||||
password_hash text NOT NULL,
|
||||
name text NOT NULL,
|
||||
bio text,
|
||||
-- Only admins may add/rename/delete from the shared platform list (see
|
||||
-- the platform table below); everyone can read it. The first account
|
||||
-- ever registered becomes an admin automatically (see CreateUser in
|
||||
-- db/query.sql) so there's always at least one without manual setup;
|
||||
-- promoting anyone beyond that is a manual
|
||||
-- `UPDATE users SET is_admin = true WHERE email = ...` for now.
|
||||
is_admin boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
+9
-6
@@ -1,18 +1,21 @@
|
||||
-- name: CreateUser :one
|
||||
-- The first account ever registered becomes an admin automatically; see
|
||||
-- the is_admin column's comment in db/migrations for why and the race this
|
||||
-- accepts. Everyone after that registers as a non-admin.
|
||||
INSERT INTO users (
|
||||
email, password_hash, name, bio
|
||||
email, password_hash, name, bio, is_admin
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
$1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users)
|
||||
)
|
||||
RETURNING id, email, name, bio, created_at;
|
||||
RETURNING id, email, name, bio, is_admin, created_at;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT id, email, password_hash, name, bio, created_at
|
||||
SELECT id, email, password_hash, name, bio, is_admin, created_at
|
||||
FROM users
|
||||
WHERE email = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT id, email, name, bio, created_at
|
||||
SELECT id, email, name, bio, is_admin, created_at
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
@@ -24,7 +27,7 @@ INSERT INTO sessions (
|
||||
);
|
||||
|
||||
-- name: GetSessionAndUser :one
|
||||
SELECT users.id, users.email, users.name, users.bio, sessions.expires_at
|
||||
SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at
|
||||
FROM sessions
|
||||
JOIN users ON users.id = sessions.user_id
|
||||
WHERE sessions.token_hash = $1 AND sessions.expires_at > now();
|
||||
|
||||
@@ -93,7 +93,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
|
||||
SetCookie: sessionCookie(token),
|
||||
Body: model.AuthResponse{
|
||||
Token: token,
|
||||
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name},
|
||||
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
@@ -126,7 +126,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
|
||||
SetCookie: sessionCookie(token),
|
||||
Body: model.AuthResponse{
|
||||
Token: token,
|
||||
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name},
|
||||
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ type authedUser struct {
|
||||
Email string
|
||||
Name string
|
||||
Bio string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
type ctxKey string
|
||||
@@ -22,7 +23,7 @@ type ctxKey string
|
||||
const userCtxKey ctxKey = "completed.user"
|
||||
|
||||
func (u authedUser) toModel() model.User {
|
||||
return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio}
|
||||
return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio, IsAdmin: u.IsAdmin}
|
||||
}
|
||||
|
||||
// userFromContext returns the authenticated user set by AuthMiddleware. It
|
||||
|
||||
@@ -64,7 +64,7 @@ func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func
|
||||
// Best-effort; a failure to record activity shouldn't fail the request.
|
||||
_ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token))
|
||||
|
||||
user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String}
|
||||
user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String, IsAdmin: row.IsAdmin}
|
||||
newCtx := huma.WithValue(ctx, userCtxKey, user)
|
||||
newCtx = huma.WithValue(newCtx, tokenCtxKey, token)
|
||||
next(newCtx)
|
||||
@@ -77,6 +77,30 @@ func addAuth(op *huma.Operation, api huma.API, queries db.Querier) {
|
||||
op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries))
|
||||
}
|
||||
|
||||
// requireAdminMiddleware runs after newAuthMiddleware (see addAdminAuth) and
|
||||
// rejects the request unless the authenticated user is an admin. It reads
|
||||
// the context value that middleware set directly, rather than calling
|
||||
// userFromContext, so a wiring mistake (this middleware attached without
|
||||
// the auth one ahead of it) fails the request with 403 instead of panicking.
|
||||
func requireAdminMiddleware(api huma.API) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
u, ok := ctx.Context().Value(userCtxKey).(authedUser)
|
||||
if !ok || !u.IsAdmin {
|
||||
huma.WriteErr(api, ctx, 403, "admin access required")
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// addAdminAuth attaches both the auth and the admin-only middleware to op,
|
||||
// for routes that mutate data every user can see (the shared platform
|
||||
// list) but only an admin should be able to change.
|
||||
func addAdminAuth(op *huma.Operation, api huma.API, queries db.Querier) {
|
||||
addAuth(op, api, queries)
|
||||
op.Middlewares = append(op.Middlewares, requireAdminMiddleware(api))
|
||||
}
|
||||
|
||||
// tokenCtxKey holds the raw bearer/cookie token for the current request, so
|
||||
// the logout handler can hash and delete the exact session that was used
|
||||
// without re-parsing the request.
|
||||
|
||||
@@ -42,10 +42,12 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
|
||||
|
||||
// --- Platform Routes ---
|
||||
//
|
||||
// Platforms are a shared list: every authenticated user reads and writes
|
||||
// the same rows, unlike games, which are private per user. Routes still
|
||||
// require auth (so the list isn't public), but none of the queries below
|
||||
// filter by user_id, because platform has no such column.
|
||||
// Platforms are a shared list: every authenticated user reads the same
|
||||
// rows, unlike games, which are private per user. None of the queries
|
||||
// below filter by user_id, because platform has no such column. Reading
|
||||
// the list only requires being logged in (addAuth); adding, renaming or
|
||||
// deleting a platform requires being an admin (addAdminAuth), since that
|
||||
// changes what everyone else sees.
|
||||
|
||||
func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
||||
listOp := huma.Operation{
|
||||
@@ -90,7 +92,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
||||
Path: "/api/platforms",
|
||||
Summary: "Create Platform",
|
||||
}
|
||||
addAuth(&createOp, api, queries)
|
||||
addAdminAuth(&createOp, api, queries)
|
||||
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) {
|
||||
platform, err := queries.CreatePlatform(ctx, input.Body.Name)
|
||||
if err != nil {
|
||||
@@ -105,7 +107,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
||||
Path: "/api/platforms/{id}",
|
||||
Summary: "Update Platform",
|
||||
}
|
||||
addAuth(&updateOp, api, queries)
|
||||
addAdminAuth(&updateOp, api, queries)
|
||||
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
|
||||
ID uuid.UUID `path:"id"`
|
||||
Body model.PlatformInput
|
||||
@@ -126,7 +128,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
||||
Path: "/api/platforms/{id}",
|
||||
Summary: "Delete Platform",
|
||||
}
|
||||
addAuth(&deleteOp, api, queries)
|
||||
addAdminAuth(&deleteOp, api, queries)
|
||||
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
|
||||
ID uuid.UUID `path:"id"`
|
||||
}) (*struct{}, error) {
|
||||
|
||||
@@ -43,6 +43,16 @@ func authedMock() *MockQuerier {
|
||||
}
|
||||
}
|
||||
|
||||
// adminMock is authedMock for a session belonging to an admin, for the
|
||||
// platform-mutation routes that require one.
|
||||
func adminMock() *MockQuerier {
|
||||
m := authedMock()
|
||||
m.GetSessionAndUserFunc = func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
|
||||
return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice", IsAdmin: true}, nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func authedRequest(method, path, body string) *http.Request {
|
||||
var req *http.Request
|
||||
if body == "" {
|
||||
@@ -118,7 +128,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
|
||||
// db/query.sql's DeletePlatform comment), so the handler has to notice
|
||||
// the 0-rows result itself and turn it into 409, not just pass through
|
||||
// whatever error (if any) the query returns.
|
||||
mockDB := authedMock()
|
||||
mockDB := adminMock()
|
||||
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
|
||||
return db.GetPlatformRow{ID: id, Name: "PC"}, nil
|
||||
}
|
||||
@@ -134,7 +144,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeletePlatformNotFound(t *testing.T) {
|
||||
mockDB := authedMock()
|
||||
mockDB := adminMock()
|
||||
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
|
||||
return db.GetPlatformRow{}, pgx.ErrNoRows
|
||||
}
|
||||
@@ -160,7 +170,7 @@ func TestCreateGameRejectsScoreOutOfRange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreatePlatform(t *testing.T) {
|
||||
mockDB := authedMock()
|
||||
mockDB := adminMock()
|
||||
mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) {
|
||||
return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil
|
||||
}
|
||||
@@ -173,6 +183,18 @@ func TestCreatePlatform(t *testing.T) {
|
||||
assert.Contains(t, rec.Body.String(), "Sega Genesis")
|
||||
}
|
||||
|
||||
func TestCreatePlatformForbiddenForNonAdmin(t *testing.T) {
|
||||
mockDB := authedMock()
|
||||
// No CreatePlatformFunc set: the admin check must reject the request
|
||||
// before the handler ever calls the database, or this test would panic.
|
||||
|
||||
e, _ := newTestAPI(mockDB)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`))
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
}
|
||||
|
||||
func TestListPlatformsIsSharedAcrossUsers(t *testing.T) {
|
||||
// Unlike games, the platform list has no user_id to scope by: every
|
||||
// authenticated user sees the same rows. ListPlatformsFunc taking no
|
||||
|
||||
@@ -44,6 +44,7 @@ type User struct {
|
||||
PasswordHash string
|
||||
Name string
|
||||
Bio pgtype.Text
|
||||
IsAdmin bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
@@ -14,6 +14,9 @@ type Querier interface {
|
||||
CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error)
|
||||
CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error)
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) error
|
||||
// The first account ever registered becomes an admin automatically; see
|
||||
// the is_admin column's comment in db/migrations for why and the race this
|
||||
// accepts. Everyone after that registers as a non-admin.
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
||||
DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error)
|
||||
// Soft delete, so the FK on game.platform_id is never touched and never
|
||||
|
||||
@@ -110,11 +110,11 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (
|
||||
email, password_hash, name, bio
|
||||
email, password_hash, name, bio, is_admin
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
$1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users)
|
||||
)
|
||||
RETURNING id, email, name, bio, created_at
|
||||
RETURNING id, email, name, bio, is_admin, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@@ -129,9 +129,13 @@ type CreateUserRow struct {
|
||||
Email string
|
||||
Name string
|
||||
Bio pgtype.Text
|
||||
IsAdmin bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// The first account ever registered becomes an admin automatically; see
|
||||
// the is_admin column's comment in db/migrations for why and the race this
|
||||
// accepts. Everyone after that registers as a non-admin.
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
||||
row := q.db.QueryRow(ctx, createUser,
|
||||
arg.Email,
|
||||
@@ -145,6 +149,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU
|
||||
&i.Email,
|
||||
&i.Name,
|
||||
&i.Bio,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
@@ -259,7 +264,7 @@ func (q *Queries) GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow
|
||||
}
|
||||
|
||||
const getSessionAndUser = `-- name: GetSessionAndUser :one
|
||||
SELECT users.id, users.email, users.name, users.bio, sessions.expires_at
|
||||
SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at
|
||||
FROM sessions
|
||||
JOIN users ON users.id = sessions.user_id
|
||||
WHERE sessions.token_hash = $1 AND sessions.expires_at > now()
|
||||
@@ -270,6 +275,7 @@ type GetSessionAndUserRow struct {
|
||||
Email string
|
||||
Name string
|
||||
Bio pgtype.Text
|
||||
IsAdmin bool
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
@@ -281,13 +287,14 @@ func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetS
|
||||
&i.Email,
|
||||
&i.Name,
|
||||
&i.Bio,
|
||||
&i.IsAdmin,
|
||||
&i.ExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, password_hash, name, bio, created_at
|
||||
SELECT id, email, password_hash, name, bio, is_admin, created_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`
|
||||
@@ -298,6 +305,7 @@ type GetUserByEmailRow struct {
|
||||
PasswordHash string
|
||||
Name string
|
||||
Bio pgtype.Text
|
||||
IsAdmin bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
@@ -310,13 +318,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.Bio,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, email, name, bio, created_at
|
||||
SELECT id, email, name, bio, is_admin, created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -326,6 +335,7 @@ type GetUserByIDRow struct {
|
||||
Email string
|
||||
Name string
|
||||
Bio pgtype.Text
|
||||
IsAdmin bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
@@ -337,6 +347,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow
|
||||
&i.Email,
|
||||
&i.Name,
|
||||
&i.Bio,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
|
||||
@@ -9,6 +9,7 @@ type User struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
|
||||
Reference in new issue
Block a user