diff options
Diffstat
| -rw-r--r-- | cmd/mirum-server/api_cli.go | 39 | +39 −0 |
| -rw-r--r-- | cmd/mirum-server/database.go | 128 | +93 −35 |
| -rw-r--r-- | cmd/mirum-server/proto/api.proto | 19 | +19 −0 |
| -rw-r--r-- | cmd/mirum-server/server_admin.go | 25 | +23 −2 |
| -rw-r--r-- | cmd/mirum-server/web/lib/errors.ts | 4 | +4 −0 |
5 files changed, 178 insertions, 37 deletions
diff --git a/cmd/mirum-server/api_cli.go b/cmd/mirum-server/api_cli.go index 8220c65..f5b93fc 100644 --- a/cmd/mirum-server/api_cli.go +++ b/cmd/mirum-server/api_cli.go @@ -232,6 +232,45 @@ func registerMessageField(cmd *cobra.Command, fd protoreflect.FieldDescriptor, f case "mirum.api.PageRequest": return nil + + case "mirum.api.Locale": + langFlag := flagName + "-language" + dfFlag := flagName + "-date-format" + flags.String(langFlag, "", "locale language (e.g. en)") + flags.String(dfFlag, "", "date format (DMY, MDY, YMD)") + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + lang, _ := fs.GetString(langFlag) + dfStr, _ := fs.GetString(dfFlag) + if lang == "" && dfStr == "" { + return nil + } + loc := &apipb.Locale{} + if lang != "" { + loc.Language = &lang + } + if dfStr != "" { + want := strings.ToUpper(dfStr) + enumVals := loc.ProtoReflect().Descriptor().Fields().ByName("date_format").Enum().Values() + var n protoreflect.EnumNumber + var found bool + for i := 0; i < enumVals.Len(); i++ { + ev := enumVals.Get(i) + name := string(ev.Name()) + if idx := strings.LastIndexByte(name, '_'); idx >= 0 && strings.ToUpper(name[idx+1:]) == want { + n = ev.Number() + found = true + break + } + } + if !found { + return fmt.Errorf("--%s: unknown value %q (valid: DMY, MDY, YMD)", dfFlag, dfStr) + } + df := apipb.DateFormat(n) + loc.DateFormat = &df + } + m.Set(fd, protoreflect.ValueOfMessage(loc.ProtoReflect())) + return nil + } } if required { diff --git a/cmd/mirum-server/database.go b/cmd/mirum-server/database.go index 27db982..5db7a19 100644 --- a/cmd/mirum-server/database.go +++ b/cmd/mirum-server/database.go @@ -32,25 +32,27 @@ import ( ) var ( - ErrAcquire = errors.New("database: failed to acquire connection") - ErrAlreadyMember = errors.New("database: already a member") - ErrEmailTaken = errors.New("database: email already taken") - ErrInvalidCreds = errors.New("database: invalid credentials") - ErrInvalidEmail = errors.New("database: invalid email") - ErrInvalidRole = errors.New("database: invalid role") - ErrInvalidSlug = errors.New("database: invalid slug") - ErrLastOwner = errors.New("database: last owner") - ErrMigrate = errors.New("database: failed to create migrator") - ErrNotImplemented = errors.New("database: filter not implemented") - ErrNotMember = errors.New("database: not a member") - ErrOpen = errors.New("database: failed to open") - ErrOrgNotFound = errors.New("database: organization not found") - ErrPing = errors.New("database: failed to ping") - ErrReservedEmail = errors.New("database: email uses a reserved domain") - ErrSlugTaken = errors.New("database: slug already taken") - ErrSoleOwner = errors.New("database: sole owner of an organization") - ErrUserNotFound = errors.New("database: user not found") - ErrWorkerNotFound = errors.New("database: worker not found") + ErrAcquire = errors.New("database: failed to acquire connection") + ErrAlreadyMember = errors.New("database: already a member") + ErrEmailTaken = errors.New("database: email already taken") + ErrInvalidCreds = errors.New("database: invalid credentials") + ErrInvalidEmail = errors.New("database: invalid email") + ErrInvalidRole = errors.New("database: invalid role") + ErrInvalidSlug = errors.New("database: invalid slug") + ErrInvalidDateFormat = errors.New("database: invalid date format") + ErrInvalidTimezone = errors.New("database: invalid timezone") + ErrLastOwner = errors.New("database: last owner") + ErrMigrate = errors.New("database: failed to create migrator") + ErrNotImplemented = errors.New("database: filter not implemented") + ErrNotMember = errors.New("database: not a member") + ErrOpen = errors.New("database: failed to open") + ErrOrgNotFound = errors.New("database: organization not found") + ErrPing = errors.New("database: failed to ping") + ErrReservedEmail = errors.New("database: email uses a reserved domain") + ErrSlugTaken = errors.New("database: slug already taken") + ErrSoleOwner = errors.New("database: sole owner of an organization") + ErrUserNotFound = errors.New("database: user not found") + ErrWorkerNotFound = errors.New("database: worker not found") ) // reservedEmailSuffix is the domain carved out for synthetic actors @@ -109,11 +111,26 @@ type DB struct { Pool *pgxpool.Pool } +type DateFormat int + +const ( + DateFormatDMY DateFormat = 1 + DateFormatMDY DateFormat = 2 + DateFormatYMD DateFormat = 3 +) + +type Locale struct { + Language *string + DateFormat *DateFormat +} + // User holds info about a user. type User struct { ID UserID Email string CreatedAt time.Time + Locale *Locale + Timezone *string } // Organization holds info about an organization. @@ -214,13 +231,21 @@ func (db *DB) Migrate(ctx context.Context) error { } migrator.AppendMigration("create_users", ` + CREATE TYPE locale_settings AS ( + language TEXT, + date_format INTEGER + ); + CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuidv7(), email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, superuser BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - deleted_at TIMESTAMPTZ + deleted_at TIMESTAMPTZ, + + locale locale_settings, + timezone TEXT ); CREATE FUNCTION app_user_id() RETURNS uuid STABLE AS $$ @@ -244,6 +269,8 @@ func (db *DB) Migrate(ctx context.Context) error { ); $$ LANGUAGE sql; `, ` + DROP TYPE locale_settings; + DROP TYPE date_format_t; DROP FUNCTION app_issuper; DROP FUNCTION app_user_id; DROP TABLE users; @@ -480,12 +507,16 @@ func (db *DB) UserGet(ctx context.Context, actor Actor, ref UserRef) (*User, err col, val := ref.where() q := sb.PostgreSQL.NewSelectBuilder() - sql, args := q.Select("id", "email", "created_at"). + sql, args := q.Select("id", "email", "created_at", + "(locale).language", "(locale).date_format", "timezone"). From("users"). Where(q.Equal(col, val), q.IsNull("deleted_at")). Build() - if err := tx.QueryRow(ctx, sql, args...).Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { + u.Locale = &Locale{} + if err := tx.QueryRow(ctx, sql, args...).Scan( + &u.ID, &u.Email, &u.CreatedAt, &u.Locale.Language, &u.Locale.DateFormat, &u.Timezone, + ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrUserNotFound } @@ -518,7 +549,8 @@ func (db *DB) UserList(ctx context.Context, actor Actor, cursor UserID, limit in } q := sb.PostgreSQL.NewSelectBuilder() - q.Select("id", "email", "created_at"). + q.Select("id", "email", "created_at", + "(locale).language", "(locale).date_format", "timezone"). From("users"). Where(q.IsNull("deleted_at")). OrderBy("id"). @@ -536,7 +568,8 @@ func (db *DB) UserList(ctx context.Context, actor Actor, cursor UserID, limit in for rows.Next() { var u User - if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { + u.Locale = &Locale{} + if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.Locale.Language, &u.Locale.DateFormat, &u.Timezone); err != nil { return err } users = append(users, u) @@ -547,13 +580,16 @@ func (db *DB) UserList(ctx context.Context, actor Actor, cursor UserID, limit in return users, total, err } +type UserUpdateParams struct { + Email *string + Password *string + Locale *Locale + Timezone *string +} + // UserUpdate updates a user's email and/or password. // Invalidates all sessions when password changes. -func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, email *string, password *string, pepper []byte) error { - if email == nil && password == nil { - return nil - } - +func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, p UserUpdateParams, pepper []byte) error { var id UserID return db.apicall(ctx, actor, func(tx pgx.Tx) error { @@ -565,24 +601,46 @@ func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, email *s return checkSelf(actor, id) }, func(tx pgx.Tx) error { - if email != nil && strings.HasSuffix(strings.ToLower(*email), reservedEmailSuffix) { + if p.Email != nil && strings.HasSuffix(strings.ToLower(*p.Email), reservedEmailSuffix) { return ErrReservedEmail } + if p.Timezone != nil { + if _, err := time.LoadLocation(*p.Timezone); err != nil { + return ErrInvalidTimezone + } + } return nil }, func(tx pgx.Tx) error { ub := sb.PostgreSQL.NewUpdateBuilder() ub.Update("users") - if email != nil { - ub.SetMore(ub.Assign("email", *email)) + var hasSet bool + if p.Email != nil { + ub.SetMore(ub.Assign("email", *p.Email)) + hasSet = true } - if password != nil { - hash, err := hashPassword(*password, pepper) + if p.Password != nil { + hash, err := hashPassword(*p.Password, pepper) if err != nil { return err } ub.SetMore(ub.Assign("password", hash)) + hasSet = true + } + if p.Locale != nil { + ub.SetMore(fmt.Sprintf( + "locale = ROW(COALESCE(%s, (locale).language), COALESCE(%s, (locale).date_format))::locale_settings", + ub.Var(p.Locale.Language), ub.Var(p.Locale.DateFormat), + )) + hasSet = true + } + if p.Timezone != nil { + ub.SetMore(ub.Assign("timezone", *p.Timezone)) + hasSet = true + } + if !hasSet { + return nil } ub.Where(ub.Equal("id", id)) @@ -595,7 +653,7 @@ func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, email *s return err } - if password != nil { + if p.Password != nil { if _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { return err } diff --git a/cmd/mirum-server/proto/api.proto b/cmd/mirum-server/proto/api.proto index 16a635e..5159447 100644 --- a/cmd/mirum-server/proto/api.proto +++ b/cmd/mirum-server/proto/api.proto @@ -95,6 +95,9 @@ enum ErrorReason { ERROR_REASON_INVALID_SLUG = 40; ERROR_REASON_INVALID_ROLE = 41; ERROR_REASON_RESERVED_EMAIL = 42; + ERROR_REASON_INVALID_LOCALE = 43; + ERROR_REASON_INVALID_DATE_FORMAT = 44; + ERROR_REASON_INVALID_TIMEZONE = 45; // Auth ERROR_REASON_UNAUTHENTICATED = 50; @@ -144,10 +147,24 @@ message PageResponse { // User management +message Locale { + optional string language = 1; + optional DateFormat date_format = 2; +} + +enum DateFormat { + DATE_FORMAT_UNSPECIFIED = 0; + DATE_FORMAT_DMY = 1; + DATE_FORMAT_MDY = 2; + DATE_FORMAT_YMD = 3; +} + message User { bytes id = 1; string email = 2; google.protobuf.Timestamp created_at = 3; + Locale locale = 4; + optional string timezone = 5; } message UserCreateRequest { @@ -178,6 +195,8 @@ message UserUpdateRequest { UserRef user = 1 [(buf.validate.field).required = true]; optional string email = 2 [(buf.validate.field).string.email = true]; optional string password = 3 [(buf.validate.field).string.min_len = 1]; + optional Locale locale = 4; + optional string timezone = 5; } message UserUpdateResponse {} diff --git a/cmd/mirum-server/server_admin.go b/cmd/mirum-server/server_admin.go index a79c377..8b6d223 100644 --- a/cmd/mirum-server/server_admin.go +++ b/cmd/mirum-server/server_admin.go @@ -60,6 +60,8 @@ var errSpecs = []struct { {ErrSoleOwner, connect.CodeFailedPrecondition, apipb.ErrorReason_ERROR_REASON_SOLE_OWNER}, {ErrInvalidSlug, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_SLUG}, {ErrInvalidRole, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_ROLE}, + {ErrInvalidDateFormat, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_DATE_FORMAT}, + {ErrInvalidTimezone, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_TIMEZONE}, {ErrReservedEmail, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_RESERVED_EMAIL}, {ErrPermissionDenied, connect.CodePermissionDenied, apipb.ErrorReason_ERROR_REASON_PERMISSION_DENIED}, {ErrUnauthenticated, connect.CodeUnauthenticated, apipb.ErrorReason_ERROR_REASON_UNAUTHENTICATED}, @@ -161,9 +163,16 @@ func pageResponse[K IDKind](items int, limit int, lastID ID[K], total int) *apip // --- Proto converters --- func userToProto(u User) *apipb.User { - return &apipb.User{ + pb := &apipb.User{ Id: u.ID.Bytes(), Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt), + Timezone: u.Timezone, } + pb.Locale = &apipb.Locale{Language: u.Locale.Language} + if u.Locale.DateFormat != nil { + df := apipb.DateFormat(*u.Locale.DateFormat) + pb.Locale.DateFormat = &df + } + return pb } func orgToProto(o Organization) *apipb.Org { @@ -248,7 +257,19 @@ func (a *consoleService) UserUpdate(ctx context.Context, req *connect.Request[ap if err != nil { return nil, mapErr(err) } - if err := a.srv.db.UserUpdate(ctx, ActorFromContext(ctx), ref, req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil { + p := UserUpdateParams{ + Email: req.Msg.Email, + Password: req.Msg.Password, + Timezone: req.Msg.Timezone, + } + if req.Msg.Locale != nil { + p.Locale = &Locale{Language: req.Msg.Locale.Language} + if req.Msg.Locale.DateFormat != nil { + df := DateFormat(*req.Msg.Locale.DateFormat) + p.Locale.DateFormat = &df + } + } + if err := a.srv.db.UserUpdate(ctx, ActorFromContext(ctx), ref, p, []byte(a.srv.cfg.Pepper)); err != nil { return nil, mapErr(err) } return connect.NewResponse(&apipb.UserUpdateResponse{}), nil diff --git a/cmd/mirum-server/web/lib/errors.ts b/cmd/mirum-server/web/lib/errors.ts index c9ec786..d09e0d0 100644 --- a/cmd/mirum-server/web/lib/errors.ts +++ b/cmd/mirum-server/web/lib/errors.ts @@ -47,6 +47,10 @@ export function textForReason(reason: ErrorReason): string { return "Invalid slug. Use lowercase letters, digits, and hyphens."; case ErrorReason.INVALID_ROLE: return "Invalid role."; + case ErrorReason.INVALID_DATE_FORMAT: + return "Invalid date format."; + case ErrorReason.INVALID_TIMEZONE: + return "Invalid timezone."; case ErrorReason.RESERVED_EMAIL: return "This email domain is reserved. Please use a different address."; case ErrorReason.UNAUTHENTICATED: |
