diff --git a/docs/screenshots/login-bonus-settings.png b/docs/screenshots/login-bonus-settings.png new file mode 100644 index 00000000000..ab9e9805362 Binary files /dev/null and b/docs/screenshots/login-bonus-settings.png differ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3e5d82b0ee6..081e0a76e2f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1821,6 +1821,15 @@ _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" _serverSettings: + _loginBonus: + title: "ログインボーナス" + enabled: "ログインボーナスを有効にする" + resetTime: "付与日の切り替え時刻(日本時間)" + resetTimeDescription: "毎日この時刻以降の最初のアクセスで、1日1回ポイントを付与します。ページを開いたままの場合は再読み込みしてください。" + minPoints: "付与ポイントの下限" + maxPoints: "付与ポイントの上限" + pointsDescription: "下限から上限までの整数をランダムに付与します。同じ値にすると固定ポイントになります。" + invalidPointsRange: "ポイントは1以上の整数で、下限が上限以下になるように設定してください。" iconUrl: "アイコン画像のURL" appIconDescription: "{host}がアプリとして表示される際のアイコンを指定します。" appIconUsageExample: "例: PWAや、スマートフォンのホーム画面にブックマークとして追加された時など" diff --git a/packages/backend/migration/1791144811075-LoginBonusSettings.js b/packages/backend/migration/1791144811075-LoginBonusSettings.js new file mode 100644 index 00000000000..60d8ce0c0cd --- /dev/null +++ b/packages/backend/migration/1791144811075-LoginBonusSettings.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class LoginBonusSettings1791144811075 { + name = 'LoginBonusSettings1791144811075' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "loginBonusResetTime" character varying(5) NOT NULL DEFAULT '00:00'`); + await queryRunner.query(`ALTER TABLE "meta" ADD "loginBonusMinPoints" integer NOT NULL DEFAULT 1`); + await queryRunner.query(`ALTER TABLE "meta" ADD "loginBonusMaxPoints" integer NOT NULL DEFAULT 5`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "loginBonusMaxPoints"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "loginBonusMinPoints"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "loginBonusResetTime"`); + } +} diff --git a/packages/backend/src/core/LoginBonusService.ts b/packages/backend/src/core/LoginBonusService.ts index 1106ba4dc4c..9d0c442f886 100644 --- a/packages/backend/src/core/LoginBonusService.ts +++ b/packages/backend/src/core/LoginBonusService.ts @@ -42,8 +42,13 @@ export class LoginBonusService { * Award login bonus points to a user if they haven't received today's bonus */ async awardLoginBonus(userId: string): Promise { - const now = new Date(); - const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; + const meta = await this.metaService.fetch(); + if (!meta.enableLoginBonus) return null; + + const [resetHour, resetMinute] = meta.loginBonusResetTime.split(':').map(Number); + // Shift the clock so the configured time in Japan starts a new bonus day. + const bonusDate = new Date(Date.now() + (9 * 60 - resetHour * 60 - resetMinute) * 60_000); + const today = `${bonusDate.getUTCFullYear()}/${bonusDate.getUTCMonth() + 1}/${bonusDate.getUTCDate()}`; const userProfile = await this.userProfilesRepository.findOne({ where: { userId }, @@ -57,26 +62,20 @@ export class LoginBonusService { return null; } - // Check if login bonus feature is enabled - const meta = await this.metaService.fetch(); - if (!meta.enableLoginBonus) { - return null; - } - // Check if user has permission to receive login bonus const policies = await this.roleService.getUserPolicies(userId); if (!policies.loginBonusGrantEnabled) { return null; } - // Award random points (1-5) - const bonusPoints = randomInt(1, 6); + const bonusPoints = randomInt(meta.loginBonusMinPoints, meta.loginBonusMaxPoints + 1); const balance = await this.db.transaction(async manager => { const claimed = await manager.createQueryBuilder() .update(MiUserProfile) .set({ lastLoginBonusDate: today }) .where('"userId" = :userId', { userId }) - .andWhere('"lastLoginBonusDate" IS DISTINCT FROM :today', { today }) + // Changing the reset time must not move the last awarded day backwards. + .andWhere('("lastLoginBonusDate" IS NULL OR "lastLoginBonusDate"::date < CAST(:today AS date))', { today }) .execute(); if (claimed.affected === 0) return null; diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 9ae907fb186..e6f92bd70dd 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -205,6 +205,22 @@ export class MiMeta { }) public enableLoginBonus: boolean; + @Column('varchar', { + length: 5, + default: '00:00', + }) + public loginBonusResetTime: string; + + @Column('integer', { + default: 1, + }) + public loginBonusMinPoints: number; + + @Column('integer', { + default: 5, + }) + public loginBonusMaxPoints: number; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index e24a5d3713f..3a869fab29c 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -30,6 +30,9 @@ export const meta = { nowLocalUsers: { type: 'integer', optional: false, nullable: false }, maxLocalUsers: { type: 'integer', optional: false, nullable: false }, enableLoginBonus: { type: 'boolean', optional: false, nullable: false }, + loginBonusResetTime: { type: 'string', optional: false, nullable: false }, + loginBonusMinPoints: { type: 'integer', optional: false, nullable: false }, + loginBonusMaxPoints: { type: 'integer', optional: false, nullable: false }, cacheRemoteFiles: { type: 'boolean', optional: false, nullable: false, @@ -839,6 +842,9 @@ export default class extends Endpoint { // eslint- remoteNotesCleaningMaxProcessingDurationInMinutes: instance.remoteNotesCleaningMaxProcessingDurationInMinutes, showRoleBadgesOfRemoteUsers: instance.showRoleBadgesOfRemoteUsers, enableLoginBonus: instance.enableLoginBonus, + loginBonusResetTime: instance.loginBonusResetTime, + loginBonusMinPoints: instance.loginBonusMinPoints, + loginBonusMaxPoints: instance.loginBonusMaxPoints, isManaged: envOption.managed, ...(envOption.managed ? { nowLocalUsers: await this.usersRepository.count({ where: { host: IsNull(), username: Not(In(['instance.actor', 'relay.actor', this.config.adminUserName ?? '', this.config.rootUserName ?? ''])) } }), diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 8f1d4fcf8bc..a76082253be 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -11,6 +11,7 @@ import { ModerationLogService } from '@/core/ModerationLogService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { MetaService } from '@/core/MetaService.js'; import { envOption } from '@/env.js'; +import { ApiError } from '@/server/api/error.js'; export const meta = { tags: ['admin'], @@ -18,12 +19,23 @@ export const meta = { requireCredential: true, requireAdmin: true, kind: 'write:admin:meta', + + errors: { + invalidLoginBonusPointsRange: { + message: 'Minimum login bonus points must not exceed maximum points.', + code: 'INVALID_LOGIN_BONUS_POINTS_RANGE', + id: '6e2ffe67-587e-41f9-887f-3355e95161e7', + }, + }, } as const; export const paramDef = { type: 'object', properties: { enableLoginBonus: { type: 'boolean' }, + loginBonusResetTime: { type: 'string', pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }, + loginBonusMinPoints: { type: 'integer', minimum: 1, maximum: 2147483647 }, + loginBonusMaxPoints: { type: 'integer', minimum: 1, maximum: 2147483647 }, disableRegistration: { type: 'boolean', nullable: true }, pinnedUsers: { type: 'array', nullable: true, items: { @@ -863,8 +875,24 @@ export default class extends Endpoint { // eslint- if (ps.enableLoginBonus !== undefined) { set.enableLoginBonus = ps.enableLoginBonus; } + if (ps.loginBonusResetTime !== undefined) { + set.loginBonusResetTime = ps.loginBonusResetTime; + } + if (ps.loginBonusMinPoints !== undefined) { + set.loginBonusMinPoints = ps.loginBonusMinPoints; + } + if (ps.loginBonusMaxPoints !== undefined) { + set.loginBonusMaxPoints = ps.loginBonusMaxPoints; + } const before = await this.metaService.fetch(true); + if (ps.loginBonusMinPoints !== undefined || ps.loginBonusMaxPoints !== undefined) { + const minPoints = ps.loginBonusMinPoints ?? before.loginBonusMinPoints; + const maxPoints = ps.loginBonusMaxPoints ?? before.loginBonusMaxPoints; + if (minPoints > maxPoints) { + throw new ApiError(meta.errors.invalidLoginBonusPointsRange); + } + } await this.metaService.update(set); diff --git a/packages/backend/test/unit/server/api/endpoints/admin/update-meta.ts b/packages/backend/test/unit/server/api/endpoints/admin/update-meta.ts new file mode 100644 index 00000000000..51f7c0d7a31 --- /dev/null +++ b/packages/backend/test/unit/server/api/endpoints/admin/update-meta.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import type { Config } from '@/config.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { MiMeta } from '@/models/Meta.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import UpdateMetaEndpoint from '@/server/api/endpoints/admin/update-meta.js'; + +describe('admin/update-meta login bonus settings', () => { + const metaService = mock(); + const moderationLogService = mock(); + const settings = Object.assign(new MiMeta(), { + enableLoginBonus: false, + loginBonusResetTime: '00:00', + loginBonusMinPoints: 1, + loginBonusMaxPoints: 5, + }); + const endpoint = new UpdateMetaEndpoint(mock(), settings, metaService, moderationLogService); + const admin = mock(); + + beforeEach(() => { + vi.clearAllMocks(); + metaService.fetch.mockResolvedValue(settings); + }); + + test('saves enablement, reset time and a fixed points amount', async () => { + const params = { enableLoginBonus: true, loginBonusResetTime: '07:30', loginBonusMinPoints: 10, loginBonusMaxPoints: 10 }; + await endpoint.exec(params, admin, null); + expect(metaService.update).toHaveBeenCalledWith(params); + }); + + test.each([ + { loginBonusMinPoints: 6 }, + { loginBonusMinPoints: 10, loginBonusMaxPoints: 9 }, + ])('rejects an inverted range, including partial updates: %j', async params => { + await expect(endpoint.exec(params, admin, null)).rejects.toMatchObject({ code: 'INVALID_LOGIN_BONUS_POINTS_RANGE' }); + expect(metaService.update).not.toHaveBeenCalled(); + }); + + test.each([ + { loginBonusResetTime: '24:00' }, + { loginBonusMinPoints: 0 }, + { loginBonusMaxPoints: 1.5 }, + ])('rejects invalid time or points: %j', async params => { + await expect(endpoint.exec(params, admin, null)).rejects.toMatchObject({ code: 'INVALID_PARAM' }); + expect(metaService.update).not.toHaveBeenCalled(); + }); + + test('keeps existing settings when changing an unrelated setting', async () => { + await endpoint.exec({ name: 'Test' }, admin, null); + expect(metaService.update).toHaveBeenCalledWith({ name: 'Test' }); + }); +}); diff --git a/packages/backend/test/unit/server/api/endpoints/i.ts b/packages/backend/test/unit/server/api/endpoints/i.ts index f90ded927e7..9b24265f971 100644 --- a/packages/backend/test/unit/server/api/endpoints/i.ts +++ b/packages/backend/test/unit/server/api/endpoints/i.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { DataSource } from 'typeorm'; import { loadConfig } from '@/config.js'; @@ -18,6 +18,7 @@ import { DEFAULT_POLICIES, RoleService } from '@/core/RoleService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import MeEndpoint from '@/server/api/endpoints/i.js'; import { LastLoginBonusDate1791103468369 } from '../../../../../migration/1791103468369-LastLoginBonusDate.js'; +import { LoginBonusSettings1791144811075 } from '../../../../../migration/1791144811075-LoginBonusSettings.js'; describe('i login bonus', () => { const config = loadConfig(); @@ -57,17 +58,25 @@ describe('i login bonus', () => { beforeEach(async () => { vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-04T03:00:00Z')); await users.deleteAll(); const now = new Date(); today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; await users.insert({ id: 'loginbonususer', username: 'alice', usernameLower: 'alice', points: 20 }); user = await users.findOneByOrFail({ id: 'loginbonususer' }) as MiLocalUser; await profiles.insert({ userId: user.id, loggedInDates: [today] }); - metaService.fetch.mockResolvedValue({ enableLoginBonus: true } as Awaited>); + metaService.fetch.mockResolvedValue({ + enableLoginBonus: true, loginBonusResetTime: '00:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5, + } as Awaited>); roleService.getUserPolicies.mockResolvedValue({ ...DEFAULT_POLICIES }); userEntityService.pack.mockImplementation(async packedUser => ({ points: (packedUser as MiUser).points }) as never); }); + afterEach(() => { + vi.useRealTimers(); + }); + test('awards once after migration even if today is already a logged-in day', async () => { const result = await endpoint.exec({}, user, null); expect(result.points).toBeGreaterThanOrEqual(21); @@ -85,7 +94,9 @@ describe('i login bonus', () => { expect(await profiles.findOneByOrFail({ userId: user.id })).toMatchObject({ loggedInDates: [today], lastLoginBonusDate: null, }); - metaService.fetch.mockResolvedValue({ enableLoginBonus: true } as Awaited>); + metaService.fetch.mockResolvedValue({ + enableLoginBonus: true, loginBonusResetTime: '00:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5, + } as Awaited>); expect((await endpoint.exec({}, user, null)).points).toBeGreaterThan(20); }); @@ -124,6 +135,64 @@ describe('i login bonus', () => { expect(notificationService.createNotification).toHaveBeenCalledTimes(1); }); + test.each([ + ['00:00', '2026-10-04T15:00:00Z'], + ['07:30', '2026-10-04T22:30:00Z'], + ])('starts a new bonus day at %s in Japan', async (resetTime, boundary) => { + metaService.fetch.mockResolvedValue({ + enableLoginBonus: true, loginBonusResetTime: resetTime, loginBonusMinPoints: 1, loginBonusMaxPoints: 5, + } as Awaited>); + await profiles.update({ userId: user.id }, { lastLoginBonusDate: '2026/10/4' }); + vi.setSystemTime(new Date(Date.parse(boundary) - 1000)); + expect((await endpoint.exec({}, user, null)).points).toBe(20); + vi.setSystemTime(new Date(boundary)); + const awarded = await endpoint.exec({}, user, null); + expect(awarded.points).toBeGreaterThan(20); + expect((await profiles.findOneByOrFail({ userId: user.id })).lastLoginBonusDate).toBe('2026/10/5'); + expect((await endpoint.exec({}, user, null)).points).toBe(awarded.points); + expect(notificationService.createNotification).toHaveBeenCalledTimes(1); + }); + + test.each([[7, 9], [12, 12]])('awards the configured points range %i–%i', async (min, max) => { + metaService.fetch.mockResolvedValue({ + enableLoginBonus: true, loginBonusResetTime: '09:00', loginBonusMinPoints: min, loginBonusMaxPoints: max, + } as Awaited>); + const result = await service.awardLoginBonus(user.id); + expect(result?.points).toBeGreaterThanOrEqual(min); + expect(result?.points).toBeLessThanOrEqual(max); + expect(result?.balance).toBe(20 + result!.points); + expect(notificationService.createNotification).toHaveBeenCalledWith(user.id, 'loginBonus', { points: result!.points }); + }); + + test('does not award an older bonus day after the reset time changes', async () => { + await profiles.update({ userId: user.id }, { lastLoginBonusDate: today }); + metaService.fetch.mockResolvedValue({ + enableLoginBonus: true, loginBonusResetTime: '13:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5, + } as Awaited>); + expect((await endpoint.exec({}, user, null)).points).toBe(20); + expect((await profiles.findOneByOrFail({ userId: user.id })).lastLoginBonusDate).toBe(today); + }); + + test('settings migration preserves enablement and supports rollback and reapplication', async () => { + const runner = db.createQueryRunner(); + const migration = new LoginBonusSettings1791144811075(); + await runner.startTransaction(); + try { + await runner.query(`INSERT INTO "meta" ("id", "enableLoginBonus") VALUES ('x', true)`); + await migration.down(runner); + await migration.up(runner); + const [settings] = await runner.query(`SELECT "enableLoginBonus", "loginBonusResetTime", "loginBonusMinPoints", "loginBonusMaxPoints" FROM "meta" WHERE "id" = 'x'`); + expect(settings).toEqual({ + enableLoginBonus: true, loginBonusResetTime: '00:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5, + }); + await migration.down(runner); + await migration.up(runner); + } finally { + await runner.rollbackTransaction(); + await runner.release(); + } + }); + test.each([false, true])('migration preserves history and initializes the guard for enableLoginBonus=%s', async enabled => { const runner = db.createQueryRunner(); const migration = new LastLoginBonusDate1791103468369(); diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index b023688685e..e4c6273887e 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -354,6 +354,36 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + +
+ + + + + + + + + + + + + + + + {{ i18n.ts._serverSettings._loginBonus.pointsDescription }} + {{ i18n.ts._serverSettings._loginBonus.invalidPointsRange }} +
+
+
+ @@ -512,12 +542,25 @@ const federationForm = useForm({ const loginBonusForm = useForm({ enableLoginBonus: meta.enableLoginBonus, + loginBonusResetTime: meta.loginBonusResetTime, + loginBonusMinPoints: meta.loginBonusMinPoints, + loginBonusMaxPoints: meta.loginBonusMaxPoints, }, async (state) => { await os.apiWithDialog('admin/update-meta', { enableLoginBonus: state.enableLoginBonus, + loginBonusResetTime: state.loginBonusResetTime, + loginBonusMinPoints: state.loginBonusMinPoints, + loginBonusMaxPoints: state.loginBonusMaxPoints, }); fetchInstance(true); }); + +const loginBonusPointsValid = computed(() => { + const { loginBonusMinPoints: min, loginBonusMaxPoints: max } = loginBonusForm.state; + return Number.isInteger(min) && Number.isInteger(max) && min >= 1 && max <= 2147483647 && min <= max; +}); +const loginBonusSettingsValid = computed(() => loginBonusPointsValid.value && /^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(loginBonusForm.state.loginBonusResetTime)); + const proxyAccountForm = useForm({ description: proxyAccount.description, }, async (state) => { diff --git a/packages/frontend/test/unit/admin-login-bonus.test.ts b/packages/frontend/test/unit/admin-login-bonus.test.ts new file mode 100644 index 00000000000..ae776274118 --- /dev/null +++ b/packages/frontend/test/unit/admin-login-bonus.test.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/vue'; +import { defineComponent, h, Suspense } from 'vue'; +import locales from 'i18n'; +import Settings from '@/pages/admin/settings.vue'; +import { i18n, updateI18n } from '@/i18n.js'; + +const fixture = vi.hoisted(() => ({ save: vi.fn(), refresh: vi.fn() })); +vi.mock('@/os.js', () => ({ apiWithDialog: fixture.save })); +vi.mock('@/instance.js', () => ({ instance: { providesTarball: true }, fetchInstance: fixture.refresh })); +vi.mock('@/page.js', () => ({ definePage: vi.fn() })); +vi.mock('@/utility/misskey-api.js', () => ({ + misskeyApi: vi.fn(async endpoint => endpoint === 'admin/meta' ? { + enableLoginBonus: false, loginBonusResetTime: '00:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5, + pinnedUsers: [], urlPreviewSensitiveList: [], federationHosts: [], deliverSuspendedSoftware: [], + federation: 'all', + } : { description: '' }), +})); +vi.mock('@/components/MkFolder.vue', () => ({ default: { + template: '
', +} })); +vi.mock('@/components/MkInput.vue', () => ({ default: { + props: ['modelValue', 'type'], emits: ['update:modelValue'], + template: '', +} })); +vi.mock('@/components/MkSwitch.vue', () => ({ default: { + props: ['modelValue'], emits: ['update:modelValue'], + template: '', +} })); +vi.mock('@/components/MkButton.vue', () => ({ default: { template: '' } })); +vi.mock('@/components/MkTextarea.vue', () => ({ default: { template: '
' } })); +vi.mock('@/components/MkRadios.vue', () => ({ default: { template: '
' } })); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function renderSettings() { + updateI18n(locales['ja-JP']); + const wrapper = defineComponent({ setup: () => () => h('div', [h(Suspense, null, { default: () => h(Settings) })]) }); + const passthrough = { template: '
' }; + const view = render(wrapper, { global: { stubs: { + PageWithHeader: passthrough, SearchIcon: passthrough, SearchLabel: passthrough, SearchText: passthrough, + SearchMarker: { inheritAttrs: false, template: '
' }, + }, directives: { panel: {} } } }); + const panel = await view.findByRole('group', { name: i18n.ts._serverSettings._loginBonus.title }); + return within(panel); +} + +test('shows login bonus settings and saves enablement, time and fixed points', async () => { + const panel = await renderSettings(); + const labels = i18n.ts._serverSettings._loginBonus; + expect((panel.getByLabelText(labels.resetTime, { exact: false }) as HTMLInputElement).value).toBe('00:00'); + await fireEvent.click(panel.getByRole('checkbox', { name: labels.enabled })); + await fireEvent.update(panel.getByLabelText(labels.resetTime, { exact: false }), '07:30'); + await fireEvent.update(panel.getByLabelText(labels.minPoints), '10'); + await fireEvent.update(panel.getByLabelText(labels.maxPoints), '10'); + await fireEvent.click(panel.getByRole('button', { name: new RegExp(i18n.ts.save) })); + await waitFor(() => expect(fixture.save).toHaveBeenCalledWith('admin/update-meta', { + enableLoginBonus: true, loginBonusResetTime: '07:30', loginBonusMinPoints: 10, loginBonusMaxPoints: 10, + })); + expect(fixture.refresh).toHaveBeenCalledWith(true); +}); + +test('disables saving an inverted range and restores saved values on discard', async () => { + const panel = await renderSettings(); + const labels = i18n.ts._serverSettings._loginBonus; + await fireEvent.update(panel.getByLabelText(labels.minPoints), '6'); + const save = panel.getByRole('button', { name: new RegExp(i18n.ts.save) }) as HTMLButtonElement; + expect(save.disabled).toBe(true); + expect(panel.getByText(labels.invalidPointsRange)).toBeTruthy(); + await fireEvent.click(panel.getByRole('button', { name: new RegExp(i18n.ts.discard) })); + await waitFor(() => expect((panel.getByLabelText(labels.minPoints) as HTMLInputElement).value).toBe('1')); + expect(fixture.save).not.toHaveBeenCalled(); +}); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 0f70fe675eb..4887869a441 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -7140,6 +7140,40 @@ export interface Locale extends ILocale { "description": string; }; "_serverSettings": { + "_loginBonus": { + /** + * ログインボーナス + */ + "title": string; + /** + * ログインボーナスを有効にする + */ + "enabled": string; + /** + * 付与日の切り替え時刻(日本時間) + */ + "resetTime": string; + /** + * 毎日この時刻以降の最初のアクセスで、1日1回ポイントを付与します。ページを開いたままの場合は再読み込みしてください。 + */ + "resetTimeDescription": string; + /** + * 付与ポイントの下限 + */ + "minPoints": string; + /** + * 付与ポイントの上限 + */ + "maxPoints": string; + /** + * 下限から上限までの整数をランダムに付与します。同じ値にすると固定ポイントになります。 + */ + "pointsDescription": string; + /** + * ポイントは1以上の整数で、下限が上限以下になるように設定してください。 + */ + "invalidPointsRange": string; + }; /** * アイコン画像のURL */ diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 5cee098dd88..48f4f653e56 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -11259,6 +11259,9 @@ export interface operations { nowLocalUsers: number; maxLocalUsers: number; enableLoginBonus: boolean; + loginBonusResetTime: string; + loginBonusMinPoints: number; + loginBonusMaxPoints: number; cacheRemoteFiles: boolean; cacheRemoteSensitiveFiles: boolean; emailRequiredForSignup: boolean; @@ -14792,6 +14795,9 @@ export interface operations { content: { 'application/json': { enableLoginBonus?: boolean; + loginBonusResetTime?: string; + loginBonusMinPoints?: number; + loginBonusMaxPoints?: number; disableRegistration?: boolean | null; pinnedUsers?: string[] | null; hiddenTags?: string[] | null;