From 284a49ce8c21d9c0f8099194a7a37b48fdcbbb83 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Sat, 11 Jul 2026 14:26:41 +0200 Subject: [PATCH 1/5] fix: task id and search --- api/src/tv-modules/start/StartManager.ts | 2 +- api/src/tv-modules/start/StartRepository.ts | 47 +++++++++++-------- api/src/tv-modules/start/start.types.ts | 14 ++++++ .../features/tasks/TaskDetailPanel.vue | 5 ++ .../features/tasks/parts/TaskIdCopy.vue | 34 ++++++++++++++ web/src/locales/en.ts | 2 + web/src/locales/ru.ts | 2 + 7 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 web/src/components/features/tasks/parts/TaskIdCopy.vue diff --git a/api/src/tv-modules/start/StartManager.ts b/api/src/tv-modules/start/StartManager.ts index 73a75e8..e34b74b 100644 --- a/api/src/tv-modules/start/StartManager.ts +++ b/api/src/tv-modules/start/StartManager.ts @@ -89,7 +89,7 @@ export class StartManager { await this.fetchSharedGoals(organizationId); const goalIds = await this.getAllGoalsIds(organizationId); - const tasks = await this.repository.searchTask(description.trim(), goalIds); + const tasks = await this.repository.searchTask({ description, goalsIds: goalIds }); return tasks; } } diff --git a/api/src/tv-modules/start/StartRepository.ts b/api/src/tv-modules/start/StartRepository.ts index 0e25382..935cf3f 100644 --- a/api/src/tv-modules/start/StartRepository.ts +++ b/api/src/tv-modules/start/StartRepository.ts @@ -1,5 +1,5 @@ -import { and, eq, inArray, isNotNull, or } from 'drizzle-orm'; -import { GoalsSchema, GoalsListSchema } from 'taskview-db-schemas'; +import { and, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm'; +import { GoalsSchema, GoalsListSchema, TasksSchema } from 'taskview-db-schemas'; import type { AppUser } from '../../core/AppUser'; import { Database } from '../../modules/db'; import { $logger } from '../../modules/logget'; @@ -8,7 +8,7 @@ import { logError } from '../../utils/api'; import { callWithCatch } from '../../utils/helpers'; import type { TagToTaskInDb } from '../tags/tags.types'; import { TaskItemForClient } from '../tasks/TaskItemForClient'; -import type { AssigneesForTaskFromDb, FetchAllListsResult, UsersByProjectsFromDb } from './start.types'; +import type { AssigneesForTaskFromDb, FetchAllListsResult, SearchTaskArgs, SearchTaskResult, UsersByProjectsFromDb } from './start.types'; //TODO: refactor export class StartRepository { @@ -371,31 +371,38 @@ export class StartRepository { return [...taskIdToTaskMap.values()]; } - async searchTask(description: string, goalsIds: number[]): Promise { - if (goalsIds.length === 0 || !description.trim()) { + async searchTask(args: SearchTaskArgs): Promise { + const description = args.description.trim(); + if (args.goalsIds.length === 0 || !description) { return []; } - const placeholders = goalsIds.map((_id, index) => { - return `$${index + 1}`; - }); - const result = await this.db.query( - `select * from tasks.tasks where goal_id in (${placeholders.join(',')}) and complete = FALSE and parent_id is null and description ILIKE $${goalsIds.length + 1}`, - [...goalsIds, `%${description}%`] + const idMatch = description.match(/^#(\d+)$/); + const searchCondition = idMatch + ? eq(TasksSchema.id, Number(idMatch[1])) + : and( + eq(TasksSchema.complete, false), + isNull(TasksSchema.parentId), + ilike(TasksSchema.description, `%${description}%`), + ); + + const result = await callWithCatch(() => + this.db.dbDrizzle + .select() + .from(TasksSchema) + .where(and(inArray(TasksSchema.goalId, args.goalsIds), searchCondition)) ); if (!result) { return []; } - const map: Map = new Map(); - - result.rows.forEach((t) => { - if (!map.get(t.id)) { - map.set(t.id, new TaskItemForClient(t)); - } - }); - - return [...map.values()]; + return result.map((task) => ({ + ...task, + tags: [], + assignedUsers: [], + historyId: null, + subtasks: [], + })); } } diff --git a/api/src/tv-modules/start/start.types.ts b/api/src/tv-modules/start/start.types.ts index d6e1e46..f6295a1 100644 --- a/api/src/tv-modules/start/start.types.ts +++ b/api/src/tv-modules/start/start.types.ts @@ -1,3 +1,17 @@ +import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas'; + +export type SearchTaskArgs = { + description: string; + goalsIds: number[]; +}; + +export type SearchTaskResult = TasksSchemaTypeForSelect & { + tags: number[]; + assignedUsers: number[]; + historyId: number | null; + subtasks: SearchTaskResult[]; +}; + export type FetchAllListsResult = { goalName: string | null; listName: string | null; diff --git a/web/src/components/features/tasks/TaskDetailPanel.vue b/web/src/components/features/tasks/TaskDetailPanel.vue index 3372f88..06daf09 100644 --- a/web/src/components/features/tasks/TaskDetailPanel.vue +++ b/web/src/components/features/tasks/TaskDetailPanel.vue @@ -26,6 +26,10 @@ + + + {{ taskId }} + + + + diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 3e7e30a..c8040ac 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -544,6 +544,8 @@ export default { subtasks: 'Subtasks', addSubtask: 'Add subtask', notFound: 'Task not found', + copyId: 'Copy task ID', + idCopied: 'Task ID copied', noTasks: 'No tasks', showCompleted: 'Show completed', hideCompleted: 'Hide completed', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 2d6324d..79fbc0f 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -517,6 +517,8 @@ export default { subtasks: 'Подзадачи', addSubtask: 'Добавить подзадачу', notFound: 'Задача не найдена', + copyId: 'Скопировать ID задачи', + idCopied: 'ID задачи скопирован', noTasks: 'Нет задач', showCompleted: 'Показать выполненные', hideCompleted: 'Скрыть выполненные', From 0a8497af593512270ea071914eb3171346325bd9 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Sat, 11 Jul 2026 20:29:27 +0200 Subject: [PATCH 2/5] feat: edit default user and change password --- api/src/tv-modules/auth/AuthController.ts | 180 +++++++++++++++ api/src/tv-modules/auth/AuthModel.ts | 36 ++- api/src/tv-modules/auth/AuthRoutes.ts | 5 + api/src/types/app.types.ts | 3 + api/src/types/auth.types.ts | 53 +++++ web/ios/App/App.xcodeproj/project.pbxproj | 2 + web/ios/App/TaskViewWidget/TodayWidget.swift | 62 +++-- .../features/account/AccountSettings.vue | 37 ++- .../account/parts/DefaultUserCredentials.vue | 217 ++++++++++++++++++ .../account/parts/NotificationSettings.vue | 5 +- .../account/parts/PasswordCodeModal.vue | 106 +++++++++ .../account/parts/PasswordSettings.vue | 214 +++++++++++++++++ .../api-tokens/parts/ApiTokenItem.vue | 2 +- web/src/locales/de.ts | 29 +++ web/src/locales/en.ts | 27 +++ web/src/locales/es.ts | 29 +++ web/src/locales/ru.ts | 27 +++ 17 files changed, 1007 insertions(+), 27 deletions(-) create mode 100644 web/src/components/features/account/parts/DefaultUserCredentials.vue create mode 100644 web/src/components/features/account/parts/PasswordCodeModal.vue create mode 100644 web/src/components/features/account/parts/PasswordSettings.vue diff --git a/api/src/tv-modules/auth/AuthController.ts b/api/src/tv-modules/auth/AuthController.ts index 4f8a4fe..f6335bb 100644 --- a/api/src/tv-modules/auth/AuthController.ts +++ b/api/src/tv-modules/auth/AuthController.ts @@ -6,7 +6,11 @@ import { z } from 'zod'; import { Email } from '../../core/Email'; import { $logger } from '../../modules/logget'; import { + ChangeDefaultUserCredentialsSchema, + ChangeOwnPasswordByPasswordSchema, + ChangeOwnPasswordSchema, ChangePasswordDataScheme, + type PasswordChangeConfirmationMode, ConfirmEmailReqDataSchema, RefreshTokenSchema, RemindPasswordSchema, @@ -23,6 +27,10 @@ import { OrganizationRepository } from '../organizations/OrganizationRepository' import { GoalsRepository } from '../goals/GoalsRepository'; const LOGIN_CODE_TTL_MS = 5 * 60 * 1000; +const PASSWORD_CHANGE_CODE_TTL_S = 15 * 60; +const PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S = 60; +// Seeded by migration 0.0.0 (app_permissions.sql) on self-hosted installs. +const DEFAULT_USER_EMAIL = 'test@mail.dest'; export default class AuthController { private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm; @@ -656,6 +664,178 @@ export default class AuthController { return res.json(newTokens); }; + private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode { + return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email'; + } + + getPasswordChangeMode = async (_req: Request, res: Response) => { + return res.status(200).send({ mode: this.passwordChangeConfirmationMode() }); + }; + + sendPasswordChangeCode = async (req: Request, res: Response) => { + if (this.passwordChangeConfirmationMode() !== 'email') { + return res.status(403).send(); + } + + const userEmail = req.appUser.getUserData()?.email; + if (!userEmail) { + return res.status(400).end(); + } + + const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true); + if (!userData) { + return res.status(400).end(); + } + + const now = Math.floor(Date.now() / 1000); + const sinceLastCode = userData.remind_password_time ? now - userData.remind_password_time : null; + if (sinceLastCode !== null && sinceLastCode < PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S) { + return res.status(429).send({ + message: 'Please wait before requesting another code.', + retryAfter: PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S - sinceLastCode, + }); + } + + // High-entropy code: the shared remind_password_code column is also redeemable + // via the unauthenticated /password/reset endpoint, so a short numeric code + // would be brute-forceable there. + const code = generateString(12); + const saved = await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, code, now); + if (!saved) { + $logger.error(`Can not save password change code for user ${userData.id}`); + return res.status(500).end(); + } + + const text = `Your TaskView password change code is ${code}\n\nUse this code to confirm your new password. The code expires in 15 minutes.\n\nIf you didn't request this change, ignore this email.`; + + Email.send({ + text, + to: userEmail, + subject: `Your TaskView password change code: ${code}`, + from: process.env.SMTP_FROM_EMAIL as string, + }) + .then((ok) => { + if (!ok) $logger.error({ to: userEmail }, 'Failed to send password change code email'); + }) + .catch((err) => $logger.error({ err, to: userEmail }, 'Failed to send password change code email')); + + return res.status(200).end(); + }; + + changeOwnPassword = async (req: Request, res: Response) => { + const userEmail = req.appUser.getUserData()?.email; + if (!userEmail) { + return res.status(400).end(); + } + + const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true); + if (!userData) { + return res.status(400).send(); + } + + if (this.passwordChangeConfirmationMode() === 'password') { + const parsedData = ChangeOwnPasswordByPasswordSchema.safeParse(req.body); + if (!parsedData.success) { + return res.status(400).send(); + } + + const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password); + if (!validPassword) { + return res.status(403).send({ field: 'currentPassword' }); + } + + return this.applyNewPassword(res, req, userData.id, parsedData.data.password); + } + + const parsedData = ChangeOwnPasswordSchema.safeParse(req.body); + if (!parsedData.success) { + return res.status(400).send(); + } + + if (!userData.remind_password_code || !userData.remind_password_time) { + return res.status(400).send(); + } + + const now = Math.floor(Date.now() / 1000); + if (now > userData.remind_password_time + PASSWORD_CHANGE_CODE_TTL_S) { + return res.status(400).send(); + } + + if (userData.remind_password_code !== parsedData.data.code) { + return res.status(400).send(); + } + + await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, null, null); + + return this.applyNewPassword(res, req, userData.id, parsedData.data.password); + }; + + private async applyNewPassword(res: Response, req: Request, userId: number, newPassword: string) { + const passwordHash = hashSync(newPassword, 10); + const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userId); + if (!result) { + $logger.error(`Can not update password for user ${userId}`); + return res.status(500).send(); + } + + const currentSessionId = req.appUser.getTokenId(); + await req.appUser.authManager.sessionStorage.deleteAllSessions(userId, currentSessionId); + + return res.status(200).send({ changed: true }); + } + + changeDefaultUserCredentials = async (req: Request, res: Response) => { + const parsedData = ChangeDefaultUserCredentialsSchema.safeParse(req.body); + if (!parsedData.success) { + return res.status(400).send(); + } + + const userEmail = req.appUser.getUserData()?.email; + if (!userEmail) { + return res.status(400).end(); + } + + const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true); + if (!userData || userData.email.toLowerCase() !== DEFAULT_USER_EMAIL) { + return res.status(403).send(); + } + + const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password); + if (!validPassword) { + return res.status(403).send({ field: 'currentPassword' }); + } + + const { login, email } = parsedData.data; + + if (login !== userData.login && (await req.appUser.authManager.repository.getUserByLogin(login))) { + return res.status(409).send({ field: 'login' }); + } + if (email !== userData.email && (await req.appUser.authManager.repository.getUserByLogin(email, true))) { + return res.status(409).send({ field: 'email' }); + } + + const updated = await req.appUser.authManager.repository.updateUserCredentials({ + userId: userData.id, + oldEmail: userData.email, + login, + email, + passwordHash: hashSync(parsedData.data.password, 10), + }); + if (updated === 'conflict') { + return res.status(409).send({ field: 'email' }); + } + if (updated !== 'ok') { + return res.status(500).send(); + } + + // JWTs carry login/email and refresh does not re-read them from the DB, + // so drop every session and make the user sign in with the new credentials. + await req.appUser.authManager.sessionStorage.deleteAllSessions(userData.id); + this.clearRefreshToken(res); + + return res.status(200).send({ changed: true }); + }; + sendDeleteAccountCode = async (req: Request, res: Response) => { const userId = req.appUser.getUserData()?.id; const userEmail = req.appUser.getUserData()?.email; diff --git a/api/src/tv-modules/auth/AuthModel.ts b/api/src/tv-modules/auth/AuthModel.ts index 8aab488..21043f1 100644 --- a/api/src/tv-modules/auth/AuthModel.ts +++ b/api/src/tv-modules/auth/AuthModel.ts @@ -1,6 +1,8 @@ +import { eq } from 'drizzle-orm'; +import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas'; import { Database } from '../../modules/db'; import { $logger } from '../../modules/logget'; -import type { RegisterUserInDb, UserDbRecord } from '../../types/auth.types'; +import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types'; export default class AuthModel { private readonly db: Database; @@ -133,6 +135,38 @@ export default class AuthModel { } } + async updateUserCredentials(args: UpdateUserCredentialsArgs): Promise { + try { + await this.db.dbDrizzle.transaction(async (tx) => { + await tx + .update(UsersSchema) + .set({ login: args.login, email: args.email, password: args.passwordHash }) + .where(eq(UsersSchema.id, args.userId)); + await tx + .update(OrganizationMembersSchema) + .set({ email: args.email }) + .where(eq(OrganizationMembersSchema.email, args.oldEmail)); + await tx + .update(CollaborationUsersSchema) + .set({ email: args.email }) + .where(eq(CollaborationUsersSchema.email, args.oldEmail)); + await tx + .update(SsoIdentitiesSchema) + .set({ email: args.email }) + .where(eq(SsoIdentitiesSchema.userId, args.userId)); + }); + return 'ok'; + } catch (error) { + // unique(organization_id, email): the new email is already an invited member of one of the user's orgs + const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code; + if (pgCode === '23505') { + return 'conflict'; + } + $logger.error(error, `Can not update credentials for user ${args.userId}`); + return 'error'; + } + } + async updateUserPassword(password: string, userId: number): Promise { try { const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2'; diff --git a/api/src/tv-modules/auth/AuthRoutes.ts b/api/src/tv-modules/auth/AuthRoutes.ts index 2614499..4c5b34f 100644 --- a/api/src/tv-modules/auth/AuthRoutes.ts +++ b/api/src/tv-modules/auth/AuthRoutes.ts @@ -2,6 +2,7 @@ import { Router, type NextFunction, type Request, type Response } from 'express' import type { Routable } from '../../types/routable.type'; import AuthController from './AuthController'; import { IsLoggedIn } from './middlewares/is-logged-in'; +import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'; import passport from './strategies/passport-login'; import { ExternalProviderScope } from './strategies/external-auth.types'; export default class AuthRoutes implements Routable { @@ -26,6 +27,10 @@ export default class AuthRoutes implements Routable { this.router.get('/confirm/email/:code/login/:login', this.authController.confirmEmail); this.router.post('/email/recovery', this.authController.remindPassword); this.router.post('/password/reset', this.authController.changeRemindedPassword); + this.router.get('/password/change/mode', [IsLoggedIn], this.authController.getPasswordChangeMode); + this.router.post('/password/change/code', [IsLoggedIn, RejectApiTokenAuth], this.authController.sendPasswordChangeCode); + this.router.post('/password/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeOwnPassword); + this.router.post('/credentials/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeDefaultUserCredentials); this.router.post('/logout', [IsLoggedIn], this.authController.logout); this.router.post('/refresh/token', this.authController.refreshTokens); this.router.post('/delete/account/code', [IsLoggedIn], this.authController.sendDeleteAccountCode); diff --git a/api/src/types/app.types.ts b/api/src/types/app.types.ts index c7c97dd..c257676 100644 --- a/api/src/types/app.types.ts +++ b/api/src/types/app.types.ts @@ -23,6 +23,9 @@ export const AppEnvSchema = z.object({ SMTP_FROM_EMAIL: z.string().optional(), APP_URL: z.string(), + // How account password changes are confirmed: code sent by email (default) or current password + PASSWORD_CHANGE_CONFIRMATION: z.enum(['email', 'password']).optional(), + TELEGRAM_BOT_TOKEN: z.string().optional(), TELEGRAM_BOT_USERNAME: z.string().optional(), TELEGRAM_WEBHOOK_SECRET: z.string().optional(), diff --git a/api/src/types/auth.types.ts b/api/src/types/auth.types.ts index 79ac09b..1046724 100644 --- a/api/src/types/auth.types.ts +++ b/api/src/types/auth.types.ts @@ -62,6 +62,59 @@ export const ChangePasswordDataScheme = z export type ChangePasswordData = z.infer; +export const ChangeOwnPasswordSchema = z + .object({ + code: z.string().min(1).max(64), + password: z.string().min(6).max(128), + passwordRepeat: z.string().max(128), + }) + .refine((data) => data.password === data.passwordRepeat, { + message: "Passwords don't match", + path: ['passwordRepeat'], + }); + +export type ChangeOwnPassword = z.infer; + +export const ChangeOwnPasswordByPasswordSchema = z + .object({ + currentPassword: z.string().min(1).max(128), + password: z.string().min(6).max(128), + passwordRepeat: z.string().max(128), + }) + .refine((data) => data.password === data.passwordRepeat, { + message: "Passwords don't match", + path: ['passwordRepeat'], + }); + +export type ChangeOwnPasswordByPassword = z.infer; + +export type PasswordChangeConfirmationMode = 'email' | 'password'; + +export const ChangeDefaultUserCredentialsSchema = z + .object({ + currentPassword: z.string().min(1).max(128), + login: z.string().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/).toLowerCase(), + email: z.string().email().max(255).toLowerCase(), + password: z.string().min(6).max(128), + passwordRepeat: z.string().max(128), + }) + .refine((data) => data.password === data.passwordRepeat, { + message: "Passwords don't match", + path: ['passwordRepeat'], + }); + +export type ChangeDefaultUserCredentials = z.infer; + +export type UpdateUserCredentialsArgs = { + userId: number; + oldEmail: string; + login: string; + email: string; + passwordHash: string; +}; + +export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error'; + export const RefreshTokenSchema = z.object({ refreshToken: z.string(), }); diff --git a/web/ios/App/App.xcodeproj/project.pbxproj b/web/ios/App/App.xcodeproj/project.pbxproj index bbb2320..5d1fdfe 100644 --- a/web/ios/App/App.xcodeproj/project.pbxproj +++ b/web/ios/App/App.xcodeproj/project.pbxproj @@ -492,6 +492,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1.49.2; @@ -520,6 +521,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1.49.2; diff --git a/web/ios/App/TaskViewWidget/TodayWidget.swift b/web/ios/App/TaskViewWidget/TodayWidget.swift index e7b1cfa..d71853b 100644 --- a/web/ios/App/TaskViewWidget/TodayWidget.swift +++ b/web/ios/App/TaskViewWidget/TodayWidget.swift @@ -48,21 +48,29 @@ struct TodayWidgetView: View { WidgetStrings.forLocale(entry.snapshot?.locale) } - private var maxSlots: Int { - switch family { - case .systemLarge: return 8 - case .systemSmall: return 4 - default: return 3 + var body: some View { + GeometryReader { geo in + TaskListTodayView( + snapshot: entry.snapshot, + strings: strings, + maxSlots: slots(for: geo.size.height), + compact: family == .systemSmall, + pinMoreToBottom: true + ) } } - var body: some View { - TaskListTodayView( - snapshot: entry.snapshot, - strings: strings, - maxSlots: maxSlots, - compact: family == .systemSmall - ) + // Rows are given a fixed frame (24pt compact / 32pt regular), so this math is exact: + // header = top + bottom padding + content; chrome = list top + bottom padding; + // each slot after the first adds a hairline divider. + private func slots(for height: CGFloat) -> Int { + let compact = family == .systemSmall + let headerHeight: CGFloat = compact ? 38 : 46 + let listChrome: CGFloat = compact ? 9 : 12 + let rowHeight: CGFloat = compact ? 24 : 32 + let dividerHeight: CGFloat = 0.34 + let available = height - headerHeight - listChrome + dividerHeight + return max(2, Int(available / (rowHeight + dividerHeight))) } } @@ -71,10 +79,12 @@ struct TaskListTodayView: View { let strings: WidgetStrings let maxSlots: Int let compact: Bool + let pinMoreToBottom: Bool - private var horizontalPadding: CGFloat { compact ? 12 : 16 } - private var rowVerticalPadding: CGFloat { compact ? 6 : 10 } - private var dividerInset: CGFloat { compact ? 38 : 48 } + private var horizontalPadding: CGFloat { compact ? 16 : 20 } + private var rowFixedHeight: CGFloat { compact ? 24 : 32 } + private var rowVerticalPadding: CGFloat { compact ? 3 : 6 } + private var dividerInset: CGFloat { compact ? 42 : 52 } private var isUpcoming: Bool { snapshot?.isUpcoming ?? false @@ -98,6 +108,8 @@ struct TaskListTodayView: View { Text(isUpcoming ? strings.upcoming : strings.today) .font(compact ? .footnote.weight(.semibold) : .headline) + .lineLimit(1) + .truncationMode(.tail) Spacer() @@ -110,8 +122,8 @@ struct TaskListTodayView: View { .background(WidgetPalette.accent, in: Capsule()) } .padding(.horizontal, horizontalPadding) - .padding(.top, 12) - .padding(.bottom, compact ? 8 : 12) + .padding(.top, compact ? 16 : 18) + .padding(.bottom, compact ? 4 : 6) .background(WidgetPalette.headerBackground) if !visibleTasks.isEmpty { @@ -125,7 +137,8 @@ struct TaskListTodayView: View { TaskRowView(task: task, strings: strings, compact: compact, showDate: isUpcoming) .padding(.horizontal, horizontalPadding) - .padding(.vertical, rowVerticalPadding) + .frame(height: pinMoreToBottom ? rowFixedHeight : nil) + .padding(.vertical, pinMoreToBottom ? 0 : rowVerticalPadding) } .transition(.opacity.combined(with: .move(edge: .trailing))) } @@ -134,16 +147,25 @@ struct TaskListTodayView: View { Divider() .padding(.leading, dividerInset) + if pinMoreToBottom { + Spacer(minLength: 0) + } + Text(strings.more(hiddenCount)) .font(compact ? .caption2 : .footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, dividerInset) .padding(.trailing, horizontalPadding) - .padding(.vertical, rowVerticalPadding) + .frame(height: pinMoreToBottom ? rowFixedHeight : nil) + .padding(.vertical, pinMoreToBottom ? 0 : rowVerticalPadding) } } - Spacer(minLength: 0) + .padding(.top, compact ? 3 : 4) + .padding(.bottom, compact ? 6 : 8) + if !(pinMoreToBottom && hiddenCount > 0) { + Spacer(minLength: 0) + } } else { Spacer() Text(snapshot == nil ? strings.openApp : strings.empty) diff --git a/web/src/components/features/account/AccountSettings.vue b/web/src/components/features/account/AccountSettings.vue index e50695a..79562c6 100644 --- a/web/src/components/features/account/AccountSettings.vue +++ b/web/src/components/features/account/AccountSettings.vue @@ -2,15 +2,39 @@
- + - + - + + + + + + + + +

{{ t('account.management') }} @@ -34,12 +58,14 @@ diff --git a/web/src/components/features/account/parts/NotificationSettings.vue b/web/src/components/features/account/parts/NotificationSettings.vue index 4ea68f5..2b33a0a 100644 --- a/web/src/components/features/account/parts/NotificationSettings.vue +++ b/web/src/components/features/account/parts/NotificationSettings.vue @@ -1,5 +1,8 @@ + + - - + + diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 8abde8c..badbc42 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -792,6 +792,15 @@ export default { friday: 'Freitag', saturday: 'Samstag', sunday: 'Sonntag', + defaultProject: 'Standardprojekt', + defaultProjectHint: 'Dieses Projekt direkt nach der Anmeldung öffnen.', + defaultProjectNone: 'Startbildschirm (Standard)', + defaultView: 'Standardansicht', + defaultViewHint: 'Welche Ansicht des Projekts geöffnet wird.', + viewTasks: 'Aufgaben', + viewKanban: 'Kanban', + viewGraph: 'Graph', + viewSprints: 'Sprints', }, taskFields: { subtasks: 'Teilaufgaben', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 1541acb..01e5ef8 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -806,6 +806,15 @@ export default { friday: 'Friday', saturday: 'Saturday', sunday: 'Sunday', + defaultProject: 'Default project', + defaultProjectHint: 'Open this project right after signing in.', + defaultProjectNone: 'Home screen (default)', + defaultView: 'Default view', + defaultViewHint: 'Which view of the default project to open.', + viewTasks: 'Tasks', + viewKanban: 'Kanban', + viewGraph: 'Graph', + viewSprints: 'Sprints', }, taskFields: { subtasks: 'Subtasks', diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 671ca6b..3839029 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -792,6 +792,15 @@ export default { friday: 'Viernes', saturday: 'Sábado', sunday: 'Domingo', + defaultProject: 'Proyecto predeterminado', + defaultProjectHint: 'Abrir este proyecto justo después de iniciar sesión.', + defaultProjectNone: 'Pantalla de inicio (predeterminado)', + defaultView: 'Vista predeterminada', + defaultViewHint: 'Qué vista del proyecto abrir.', + viewTasks: 'Tareas', + viewKanban: 'Kanban', + viewGraph: 'Grafo', + viewSprints: 'Sprints', }, taskFields: { subtasks: 'Subtareas', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index cfbb1f4..4bbfbd9 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -779,6 +779,15 @@ export default { friday: 'Пятница', saturday: 'Суббота', sunday: 'Воскресенье', + defaultProject: 'Проект по умолчанию', + defaultProjectHint: 'Открывать этот проект сразу после входа.', + defaultProjectNone: 'Главный экран (по умолчанию)', + defaultView: 'Вид по умолчанию', + defaultViewHint: 'Какой вид проекта открывать.', + viewTasks: 'Задачи', + viewKanban: 'Канбан', + viewGraph: 'Граф', + viewSprints: 'Спринты', }, taskFields: { subtasks: 'Подзадачи', diff --git a/web/src/uiCustomization/sections/others.ts b/web/src/uiCustomization/sections/others.ts index 976e063..268cda8 100644 --- a/web/src/uiCustomization/sections/others.ts +++ b/web/src/uiCustomization/sections/others.ts @@ -1,3 +1,4 @@ +import { markRaw } from 'vue' import UiCustomizationOthers from '@/components/features/ui-customization/UiCustomizationOthers.vue' import type { UiCustomizationSectionDef } from '../types' @@ -5,5 +6,7 @@ export const othersSection: UiCustomizationSectionDef = { kind: 'custom', id: 'others', labelKey: 'uiCustomization.sections.others', - component: UiCustomizationOthers, + // markRaw: the def ends up inside reactive tab items — a component proxied by + // reactivity triggers a Vue warning and needless overhead + component: markRaw(UiCustomizationOthers), } From 83979ac47ca719aa68b5775f4ed1b161efb84729 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Sat, 11 Jul 2026 22:39:47 +0200 Subject: [PATCH 5/5] chore: docs --- docs/1.getting-started/2.installation.md | 45 +++++++++--------- docs/2.features/12.ui-customization.md | 47 +++++++++++++++++++ .../1.environment-variables.md | 15 ++++++ docs/4.configuration/2.authentication.md | 28 +++++++++++ 4 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 docs/2.features/12.ui-customization.md diff --git a/docs/1.getting-started/2.installation.md b/docs/1.getting-started/2.installation.md index 8bf046d..4dc7cec 100644 --- a/docs/1.getting-started/2.installation.md +++ b/docs/1.getting-started/2.installation.md @@ -144,6 +144,10 @@ services: taskview-webapp: image: gimanhead/taskview-ce-webapp:latest restart: unless-stopped + environment: + # The web app will always use this API server and hide the server selector on the login page. + # Remove this variable if you want to pick the API server manually on the login page. + TASKVIEW_API_URL: "http://localhost:1725" ports: - "8888:80" # Enable for realtime notification read https://taskview.tech/docs/configuration/environment-variables#centrifugo-configuration-file @@ -175,14 +179,16 @@ Go to [http://localhost:8888](http://localhost:8888) in your browser. You'll see ### Configure the API server -Before logging in, you need to tell the web app where the API server is running. Click the **server settings** icon on the login page and add the API server URL: +The web app (port 8888) serves the frontend, while the API server (port 1725) handles authentication, projects, tasks, and all backend operations. + +If you set `TASKVIEW_API_URL` on the `taskview-webapp` service (as in the compose file above), there is nothing to configure — the web app already knows where the API is, and the server selector is hidden from the login page. + +Without `TASKVIEW_API_URL`, click the **server settings** section on the login page and add the API server URL manually: ``` http://localhost:1725 ``` -This is the API server that handles authentication, projects, tasks, and all backend operations. The web app (port 8888) serves the frontend, while the API server (port 1725) handles the data. - ### Log in with the default user The database migration creates a default user so you can log in right away: @@ -193,31 +199,25 @@ The database migration creates a default user so you can log in right away: Use these credentials to verify that everything is working - check that the UI loads, you can create a project, add tasks, etc. ::callout{icon="i-lucide-alert-triangle" color="error"} -**Important:** The default user is for initial setup only. Once you've confirmed the system works, delete the default user and create your own account with a secure password. +**Important:** The default credentials are publicly known — anyone who has read this page can sign in to a fresh installation. Claim the account right after the first login. :: -### Replacing the default user +### Claim the default account + +Make the default account your own — no SMTP or database access needed: 1. Log in with the default credentials -2. Register a new account with your real email and a strong password -3. Delete the default `admin` account +2. Open **Account settings** — the highlighted **Login and email** card is shown at the top (it is visible only to the default user) +3. Set your own login, email and a strong password, confirm with the current password (`user1!#Q`), and click **Save and sign out** +4. Sign in again with your new login and password -If you prefer to create the first user directly in the database, generate a password hash: +![The Login and email card in Account settings for claiming the default account](/taskview/change-def-account.png) -```ts -import { hashSync } from 'bcryptjs' +Your organizations, projects and permissions are preserved. Once the email is changed, the card disappears and the claim endpoint is disabled. -const passwordHash = hashSync('your-secure-password', 12) -console.log(passwordHash) -``` - -Or as a one-liner: - -```bash -node -e "console.log(require('bcryptjs').hashSync('your-secure-password', 12))" -``` - -Then insert the user into the database with the generated hash. +::callout{icon="i-lucide-mail" color="info"} +Changing the password later requires a confirmation code sent by email. If your installation has no SMTP, set `PASSWORD_CHANGE_CONFIRMATION="password"` in `.env.taskview` so password changes are confirmed with the current password instead. See [Environment Variables](/docs/configuration/environment-variables#authentication). +:: ## Updating @@ -233,7 +233,8 @@ The migration container will automatically apply any new database changes on sta ## Production tips - **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS -- **Update `APP_URL` and `API_URL`** in `.env.taskview` to match your production domain +- **Update `APP_URL`** in `.env.taskview` and `TASKVIEW_API_URL` on the webapp service to match your production domains +- **Trim the login page** — set `AUTH_LOGIN_METHODS` in `.env.taskview` to offer only the sign-in methods you actually use (e.g. `AUTH_LOGIN_METHODS="password"`). Google/GitHub/Apple buttons are shown only when the provider is configured. - **Back up the database** - the `pgdata` volume contains all your data - **Set `restart: unless-stopped`** on all services so they survive server reboots - **SMTP setup** - add SMTP variables to `.env.taskview` if you want email features (password recovery, invitations). See [Configuration](/docs/configuration/environment-variables) for details. diff --git a/docs/2.features/12.ui-customization.md b/docs/2.features/12.ui-customization.md new file mode 100644 index 0000000..735be93 --- /dev/null +++ b/docs/2.features/12.ui-customization.md @@ -0,0 +1,47 @@ +--- +title: UI Customization +description: Personalize TaskView per user - reorder and hide task fields and analytics blocks, set the first day of week, and pick a default project and view to open right after signing in. +navigation: + icon: i-lucide-sliders-horizontal +--- + +TaskView lets every user tune the interface to how they actually work. All settings on this page are personal — they are stored per user on the server and follow you across devices and browsers. + +Open **Settings → UI customization** from the user menu. + +## Reorder and hide items + +Three sections are driven by drag-and-drop lists: + +- **Tasks** — the fields shown in the task detail view (note, status, priority, assignees, tags, deadline, sprint, estimate, time tracking, history, …) +- **Analytics — Indicators** — the KPI tiles on the analytics page +- **Analytics — Charts** — the charts on the analytics page + +For every item you can: + +- **Reorder** — drag by the handle on the left +- **Show / hide** — toggle visibility +- **Narrow / wide** — for task fields, choose whether the field takes half or the full width of the detail view + +All available items are listed here; permission checks still apply at render time, so an enabled item may stay hidden if you lack the permission to see it. + +## Others + +### First day of week + +Sets which day calendars start on. Applies to all date pickers across the app. "Default" follows your locale. + +### Default project and view + +Normally, after signing in you land on the home screen and navigate to your project and board manually. If you always start in the same place, set it as the default: + +- **Default project** — the project TaskView opens right after you sign in (or reopen the app with an active session) +- **Default view** — which view of that project to open: **Tasks**, **Kanban**, **Graph** or **Sprints** + +When a default project is set, a quick-jump button with the project name also appears in the sidebar next to **Inbox** — click it from anywhere to return to your project in the chosen view. + +If the default project is deleted or you lose access to it, TaskView falls back to the home screen. Choose "Home screen (default)" to turn the feature off. + +## How it is stored + +Preferences are saved automatically (no Save button) through the `ui-preferences` API and kept per user account. Resetting the browser or switching devices does not lose them. diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index fc4df49..13f9386 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -27,6 +27,14 @@ These must match your PostgreSQL setup. | `APP_URL` | Yes | https://app.taskview.tech | Full URL of the web app (e.g. `https://tasks.company.com`). Used for OAuth redirects and email links. | | `TRUST_PROXY` | No | `false` | Set when running behind a reverse proxy so `X-Forwarded-Proto`/`X-Forwarded-For` are honoured (correct `https` URLs, real client IP). Use the number of proxies in front of the app (`1` for a single Caddy/nginx), or an IP/subnet list (`10.0.0.0/8`, `uniquelocal`). Leave unset for direct access. Avoid `true` (trusts any hop, allows header spoofing). | +## Web app + +Unlike everything else on this page, this variable is set on the **web app container** (`taskview-webapp`), not in `.env.taskview`. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `TASKVIEW_API_URL` | No | - | Pins the API server URL for the web app (e.g. `https://api.company.com`). When set, the "Select server" section disappears from the login page and the app always talks to this API. When unset, users pick the API server on the login page themselves. | + ## Authentication | Variable | Required | Default | Description | @@ -35,6 +43,8 @@ These must match your PostgreSQL setup. | `ACCESS_LIFE_TIME` | No | `1d` | How long access tokens are valid. Examples: `1h`, `1d`, `7d` | | `REFRESH_LIFE_TIME` | No | `2d` | How long refresh tokens are valid | | `JWT_ALG` | No | `HS256` | JWT signing algorithm | +| `AUTH_LOGIN_METHODS` | No | all enabled | Comma-separated list of login methods to offer: `magic-link`, `password`, `sso`, `social`. Disabled methods disappear from the login page and their API endpoints return 403. The API refuses to start if the list contains a typo or disables every method. | +| `PASSWORD_CHANGE_CONFIRMATION` | No | `email` | How account password changes are confirmed: `email` — a confirmation code is sent to the user's email (requires SMTP); `password` — the user confirms with their current password (works without SMTP, recommended for installs without a mail server). | ::callout{icon="i-lucide-shield" color="warning"} Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"` @@ -209,6 +219,11 @@ JWT_SIGN="secret" ACCESS_LIFE_TIME="3d" REFRESH_LIFE_TIME="9d" +# Login methods offered on the login page (unset = all enabled) +#AUTH_LOGIN_METHODS="magic-link,password,sso,social" +# Password change confirmation: "email" (code by email, needs SMTP) or "password" (no SMTP needed) +#PASSWORD_CHANGE_CONFIRMATION="email" + SMTP_HOST=smtp SMTP_PORT=587 SMTP_USERNAME= diff --git a/docs/4.configuration/2.authentication.md b/docs/4.configuration/2.authentication.md index 9bfe753..d338bd1 100644 --- a/docs/4.configuration/2.authentication.md +++ b/docs/4.configuration/2.authentication.md @@ -7,6 +7,21 @@ navigation: TaskView supports multiple ways to sign in - email/password, email/code, GitHub, Google, and Apple. You can enable whichever methods make sense for your team. +## Choosing login methods + +By default the login page offers every method. Use the `AUTH_LOGIN_METHODS` environment variable to offer only the ones you need: + +```env +# Comma-separated list: magic-link, password, sso, social +AUTH_LOGIN_METHODS="password,sso" +``` + +Disabled methods disappear from the login page and their API endpoints return 403 — the setting is enforced server-side, not just hidden in the UI. Google/GitHub/Apple buttons are additionally shown only when the provider is actually configured, so unconfigured providers never render dead buttons. + +::callout{icon="i-lucide-shield" color="warning"} +The API refuses to start if `AUTH_LOGIN_METHODS` contains an unknown value or disables every method — a broken config can't silently lock everyone out. +:: + ## Email and password This is the default method and works out of the box. Users register with an email and password, and log in the same way (email conformation is required). @@ -14,10 +29,23 @@ This is the default method and works out of the box. Users register with an emai If you have SMTP configured, users will receive a confirmation email after registration. Without SMTP, email confirmation is skipped and accounts should be activated manually. +### Changing your password + +Users can set or change their password from **Account settings → Password**. How the change is confirmed depends on the `PASSWORD_CHANGE_CONFIRMATION` environment variable: + +- `email` (default) — a confirmation code is sent to the user's email. Requires SMTP. +- `password` — the user confirms with their current password. No SMTP needed; recommended for installations without a mail server (password login is the only way in there, so every user knows their password). + +After a successful change all other sessions are signed out; the current one stays active. + ### Password recovery Requires SMTP. Users click "Forgot password" on the login screen, enter their email, and receive a reset link. Without SMTP configured, password recovery is not available - you'll need to reset passwords manually in the database. +### The default user (self-hosted) + +Fresh installations ship a preinstalled user (`user` / `user1!#Q`). That account gets a dedicated **Login and email** card in Account settings to claim it in one step — set your own login, email and password, confirmed by the current password, no SMTP required. See [Installation → Claim the default account](/docs/getting-started/installation#claim-the-default-account). + ## OAuth providers TaskView can use external providers for login. This is separate from the integration OAuth (which is for connecting GitHub/GitLab repositories).