mirror of
https://github.com/outline/outline.git
synced 2026-05-03 08:00:15 -05:00
feat: store user timezone (#7902)
* feat: store user timezone * tz validation
This commit is contained in:
@@ -50,6 +50,10 @@ class User extends ParanoidModel {
|
||||
@observable
|
||||
notificationSettings: NotificationSettings;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
timezone?: string;
|
||||
|
||||
@observable
|
||||
email: string;
|
||||
|
||||
|
||||
@@ -225,6 +225,12 @@ export default class AuthStore extends Store<Team> {
|
||||
});
|
||||
}
|
||||
|
||||
const currTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (data.user.timezone !== currTimezone) {
|
||||
const user = this.rootStore.users.get(data.user.id)!;
|
||||
void user.save({ timezone: currTimezone });
|
||||
}
|
||||
|
||||
// Redirect to the correct custom domain or team subdomain if needed
|
||||
// Occurs when the (sub)domain is changed in admin and the user hits an old url
|
||||
const { hostname, pathname } = window.location;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("users", "timezone", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn("users", "timezone");
|
||||
},
|
||||
};
|
||||
@@ -183,6 +183,10 @@ class User extends ParanoidModel<
|
||||
@Column(DataType.STRING)
|
||||
language: keyof typeof locales | null;
|
||||
|
||||
@AllowNull
|
||||
@Column(DataType.STRING)
|
||||
timezone: string | null;
|
||||
|
||||
@AllowNull
|
||||
@IsUrlOrRelativePath
|
||||
@Length({ max: 4096, msg: "avatarUrl must be less than 4096 characters" })
|
||||
|
||||
@@ -10,6 +10,7 @@ exports[`presents a user 1`] = `
|
||||
"lastActiveAt": undefined,
|
||||
"name": "Test User",
|
||||
"role": "member",
|
||||
"timezone": undefined,
|
||||
"updatedAt": undefined,
|
||||
}
|
||||
`;
|
||||
@@ -24,6 +25,7 @@ exports[`presents a user without slack data 1`] = `
|
||||
"lastActiveAt": undefined,
|
||||
"name": "Test User",
|
||||
"role": "member",
|
||||
"timezone": undefined,
|
||||
"updatedAt": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -21,6 +21,7 @@ type UserPresentation = {
|
||||
language?: string;
|
||||
preferences?: UserPreferences | null;
|
||||
notificationSettings?: NotificationSettings;
|
||||
timezone?: string | null;
|
||||
};
|
||||
|
||||
export default function presentUser(
|
||||
@@ -37,6 +38,7 @@ export default function presentUser(
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
lastActiveAt: user.lastActiveAt,
|
||||
timezone: user.timezone,
|
||||
};
|
||||
|
||||
if (options.includeDetails) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
import { NotificationEventType, UserPreference, UserRole } from "@shared/types";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import User from "@server/models/User";
|
||||
import { zodEnumFromObjectKeys } from "@server/utils/zod";
|
||||
import { zodEnumFromObjectKeys, zodTimezone } from "@server/utils/zod";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
const BaseIdSchema = z.object({
|
||||
@@ -84,6 +84,7 @@ export const UsersUpdateSchema = BaseSchema.extend({
|
||||
avatarUrl: z.string().nullish(),
|
||||
language: zodEnumFromObjectKeys(locales).optional(),
|
||||
preferences: z.record(z.nativeEnum(UserPreference), z.boolean()).optional(),
|
||||
timezone: zodTimezone().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -702,6 +702,19 @@ describe("#users.update", () => {
|
||||
expect(body.data.preferences.rememberLastPath).toBe(true);
|
||||
});
|
||||
|
||||
it("should update user timezone", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
timezone: "Asia/Calcutta",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.timezone).toEqual("Asia/Calcutta");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.update");
|
||||
const body = await res.json();
|
||||
|
||||
@@ -209,7 +209,8 @@ router.post(
|
||||
async (ctx: APIContext<T.UsersUpdateReq>) => {
|
||||
const { auth, transaction } = ctx.state;
|
||||
const actor = auth.user;
|
||||
const { id, name, avatarUrl, language, preferences } = ctx.input.body;
|
||||
const { id, name, avatarUrl, language, preferences, timezone } =
|
||||
ctx.input.body;
|
||||
|
||||
let user: User | null = actor;
|
||||
if (id) {
|
||||
@@ -236,6 +237,9 @@ router.post(
|
||||
user.setPreference(key, preferences[key] as boolean);
|
||||
}
|
||||
}
|
||||
if (timezone) {
|
||||
user.timezone = timezone;
|
||||
}
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "users.update",
|
||||
|
||||
@@ -15,3 +15,16 @@ export const zodIconType = () =>
|
||||
z.string().regex(emojiRegex()),
|
||||
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||
]);
|
||||
|
||||
export const zodTimezone = () =>
|
||||
z.string().refine(
|
||||
(timezone) => {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "invalid timezone" }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user