aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-05-04 17:11:15 +0100
committerGitHub <noreply@github.com>2026-05-04 17:11:15 +0100
commit76e26a25e9c09b4fa165590482fd6cd50a7e2a4b (patch)
tree2cb5c3d3bc986ab1887a8d0134544e6827171954
parentc22bf6ccec858e60c3829b3e53ff74fd069fd944 (diff)
parentca483864b5f1f8048905c9016aff20ef36395840 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Merge pull request #10 from dimidiumlabs/i18n-backend
User settings infrastracture: lang, date format, timezone
Diffstat
-rw-r--r--cmd/mirum-server/api_cli.go39+39 −0
-rw-r--r--cmd/mirum-server/database.go128+93 −35
-rw-r--r--cmd/mirum-server/proto/api.proto19+19 −0
-rw-r--r--cmd/mirum-server/server_admin.go25+23 −2
-rw-r--r--cmd/mirum-server/web/lib/errors.ts4+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: