diff --git a/api/src/App.ts b/api/src/App.ts index 8d985ec..4773b3f 100644 --- a/api/src/App.ts +++ b/api/src/App.ts @@ -5,6 +5,7 @@ import { corsMiddleware } from './middlewares/cors'; import errorHandler from './middlewares/error-handler'; import routes from './routes'; import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login'; +import { LoginMethods } from './tv-modules/auth/LoginMethods'; import cookieParser from 'cookie-parser'; import { registerAllEventHandlers, startAllWorkers } from './core/all-events'; @@ -13,6 +14,8 @@ export default class App { public port: number; constructor(port: number) { + LoginMethods.validateOnStartup(); + this.app = express(); this.port = port; diff --git a/api/src/tv-modules/auth/AuthController.ts b/api/src/tv-modules/auth/AuthController.ts index 4f8a4fe..389ba3b 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, @@ -15,6 +19,7 @@ import { UserJwtPayloadSchema, } from '../../types/auth.types'; import { generateString, isEmail, time } from '../../utils/helpers'; +import { LoginMethods } from './LoginMethods'; import EnEmailTemplate from './mail/confirm-email-en'; import RuEmailTemplate from './mail/confirm-email-ru'; import LoginCodeEmailTemplate from './mail/login-code-en'; @@ -23,6 +28,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 +665,187 @@ export default class AuthController { return res.json(newTokens); }; + getLoginOptions = async (_req: Request, res: Response) => { + return res.status(200).send({ + magicLink: LoginMethods.isEnabled('magic-link'), + password: LoginMethods.isEnabled('password'), + sso: LoginMethods.isEnabled('sso'), + socialProviders: LoginMethods.availableSocialProviders(), + }); + }; + + 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..fc5408e 100644 --- a/api/src/tv-modules/auth/AuthRoutes.ts +++ b/api/src/tv-modules/auth/AuthRoutes.ts @@ -2,6 +2,8 @@ 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 { RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method'; import passport from './strategies/passport-login'; import { ExternalProviderScope } from './strategies/external-auth.types'; export default class AuthRoutes implements Routable { @@ -19,13 +21,18 @@ export default class AuthRoutes implements Routable { } initRoutes() { - this.router.post('/send-login-code', this.authController.sendLoginCode); - this.router.post('/login-by-code', this.authController.loginByCode); - this.router.post('/login', this.authController.login); + this.router.get('/login-options', this.authController.getLoginOptions); + this.router.post('/send-login-code', [RequireLoginMethod('magic-link')], this.authController.sendLoginCode); + this.router.post('/login-by-code', [RequireLoginMethod('magic-link')], this.authController.loginByCode); + this.router.post('/login', [RequireLoginMethod('password')], this.authController.login); this.router.post('/registration', this.authController.registration); 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.post('/email/recovery', [RequireLoginMethod('password')], this.authController.remindPassword); + this.router.post('/password/reset', [RequireLoginMethod('password')], 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); @@ -33,6 +40,7 @@ export default class AuthRoutes implements Routable { this.router.get( '/provider/:providerName', + RequireSocialProvider, (req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, { scope: ExternalProviderScope[req.params.providerName], session: false, @@ -43,6 +51,7 @@ export default class AuthRoutes implements Routable { ); this.router.get( '/provider/:providerName/callback', + RequireSocialProvider, (req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, { scope: ExternalProviderScope[req.params.providerName], session: false })(req, res, next), @@ -51,6 +60,7 @@ export default class AuthRoutes implements Routable { this.router.post( '/provider/:providerName/callback', + RequireSocialProvider, (req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, { scope: ExternalProviderScope[req.params.providerName], session: false })(req, res, next), diff --git a/api/src/tv-modules/auth/LoginMethods.ts b/api/src/tv-modules/auth/LoginMethods.ts new file mode 100644 index 0000000..ef42405 --- /dev/null +++ b/api/src/tv-modules/auth/LoginMethods.ts @@ -0,0 +1,66 @@ +import type { LoginMethod } from '../../types/auth.types'; + +export class LoginMethods { + static readonly ALL: LoginMethod[] = ['magic-link', 'password', 'sso', 'social']; + + static enabled(): Set { + const raw = process.env.AUTH_LOGIN_METHODS; + if (!raw || !raw.trim()) { + return new Set(LoginMethods.ALL); + } + return new Set(LoginMethods.parse(raw).valid); + } + + static isEnabled(method: LoginMethod): boolean { + return LoginMethods.enabled().has(method); + } + + static validateOnStartup(): void { + const raw = process.env.AUTH_LOGIN_METHODS; + if (!raw || !raw.trim()) return; + + const { valid, invalid } = LoginMethods.parse(raw); + if (invalid.length > 0) { + throw new Error( + `AUTH_LOGIN_METHODS contains unknown values: ${invalid.join(', ')}. Allowed: ${LoginMethods.ALL.join(', ')}` + ); + } + if (valid.length === 0) { + throw new Error('AUTH_LOGIN_METHODS disables every login method — nobody would be able to sign in'); + } + } + + static configuredSocialProviders(): string[] { + const providers: string[] = []; + if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_CALLBACK_URL) { + providers.push('google'); + } + if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_CALLBACK_URL) { + providers.push('github'); + } + if ( + process.env.APPLE_CLIENT_ID && + process.env.APPLE_TEAM_ID && + process.env.APPLE_KEY_ID && + process.env.APPLE_CALLBACK_URL && + process.env.APPLE_KEY_LOCATION + ) { + providers.push('apple'); + } + return providers; + } + + static availableSocialProviders(): string[] { + return LoginMethods.isEnabled('social') ? LoginMethods.configuredSocialProviders() : []; + } + + private static parse(raw: string): { valid: LoginMethod[]; invalid: string[] } { + const values = raw + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + const valid = values.filter((value): value is LoginMethod => (LoginMethods.ALL as string[]).includes(value)); + const invalid = values.filter((value) => !(LoginMethods.ALL as string[]).includes(value)); + return { valid, invalid }; + } +} diff --git a/api/src/tv-modules/auth/middlewares/require-login-method.ts b/api/src/tv-modules/auth/middlewares/require-login-method.ts new file mode 100644 index 0000000..c45a1f9 --- /dev/null +++ b/api/src/tv-modules/auth/middlewares/require-login-method.ts @@ -0,0 +1,20 @@ +import type { NextFunction, Request, Response } from 'express'; +import type { LoginMethod } from '../../../types/auth.types'; +import { LoginMethods } from '../LoginMethods'; + +export const RequireLoginMethod = (method: LoginMethod) => { + return (_req: Request, res: Response, next: NextFunction) => { + if (!LoginMethods.isEnabled(method)) { + return res.status(403).send(); + } + return next(); + }; +}; + +export const RequireSocialProvider = (req: Request, res: Response, next: NextFunction) => { + const providerName = String(req.params.providerName || '').toLowerCase(); + if (!LoginMethods.availableSocialProviders().includes(providerName)) { + return res.status(403).send(); + } + return next(); +}; diff --git a/api/src/tv-modules/auth/strategies/apple.strategy.ts b/api/src/tv-modules/auth/strategies/apple.strategy.ts index 8b914d8..9687ec7 100644 --- a/api/src/tv-modules/auth/strategies/apple.strategy.ts +++ b/api/src/tv-modules/auth/strategies/apple.strategy.ts @@ -16,8 +16,7 @@ export function initAppleStrategy() { !process.env.APPLE_KEY_ID || !process.env.APPLE_CALLBACK_URL || !process.env.APPLE_KEY_LOCATION) { - $logger.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set"); - console.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set"); + $logger.debug("Apple login is not configured (APPLE_CLIENT_ID / APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_CALLBACK_URL / APPLE_KEY_LOCATION) — skipping"); return; } diff --git a/api/src/tv-modules/auth/strategies/github.strategy.ts b/api/src/tv-modules/auth/strategies/github.strategy.ts index fe623d2..a95a363 100644 --- a/api/src/tv-modules/auth/strategies/github.strategy.ts +++ b/api/src/tv-modules/auth/strategies/github.strategy.ts @@ -7,8 +7,7 @@ import type { VerifyCallback } from "passport-google-oauth20"; export function initGithubStrategy() { if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET || !process.env.GITHUB_CALLBACK_URL) { - $logger.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set"); - console.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set"); + $logger.debug("GitHub login is not configured (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / GITHUB_CALLBACK_URL) — skipping"); return; } diff --git a/api/src/tv-modules/auth/strategies/google.strategy.ts b/api/src/tv-modules/auth/strategies/google.strategy.ts index ba6ca7f..4f56acb 100644 --- a/api/src/tv-modules/auth/strategies/google.strategy.ts +++ b/api/src/tv-modules/auth/strategies/google.strategy.ts @@ -5,8 +5,7 @@ import type { ExternalAuthUser } from "./external-auth.types"; export function initGoogleStrategy() { if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_CALLBACK_URL) { - $logger.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set"); - console.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set"); + $logger.debug("Google login is not configured (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_CALLBACK_URL) — skipping"); return; } const options = { diff --git a/api/src/tv-modules/sso/SsoRoutes.ts b/api/src/tv-modules/sso/SsoRoutes.ts index e60a63e..2db430a 100644 --- a/api/src/tv-modules/sso/SsoRoutes.ts +++ b/api/src/tv-modules/sso/SsoRoutes.ts @@ -3,6 +3,7 @@ import type { Routable } from '../../types/routable.type' import { IsLoggedIn } from '../auth/middlewares/is-logged-in' import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin' import { IsSsoConfigAdmin } from './middlewares/IsSsoConfigAdmin' +import { RequireLoginMethod } from '../auth/middlewares/require-login-method' import { SsoController } from './SsoController' export default class SsoRoutes implements Routable { @@ -20,10 +21,10 @@ export default class SsoRoutes implements Routable { } initRoutes() { - this.router.get('/providers', this.controller.listPublicProviders) - this.router.get('/login/:configId', this.controller.initiateLogin) - this.router.get('/callback/:configId', this.controller.handleCallback) - this.router.post('/callback/:configId', this.controller.handleCallback) + this.router.get('/providers', [RequireLoginMethod('sso')], this.controller.listPublicProviders) + this.router.get('/login/:configId', [RequireLoginMethod('sso')], this.controller.initiateLogin) + this.router.get('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback) + this.router.post('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback) this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata) this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs) 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/api/src/tv-modules/ui-preferences/types.ts b/api/src/tv-modules/ui-preferences/types.ts index 61789d9..26a2a0f 100644 --- a/api/src/tv-modules/ui-preferences/types.ts +++ b/api/src/tv-modules/ui-preferences/types.ts @@ -44,6 +44,8 @@ const firstDayOfWeekArkType = type('number.integer').narrow((v, ctx) => export const UiSettingsArkType = type({ 'firstDayOfWeek?': firstDayOfWeekArkType, + 'defaultProjectId?': 'number.integer >= 1', + 'defaultView?': "'tasks' | 'kanban' | 'graph' | 'sprints'", }) export type UiSettings = typeof UiSettingsArkType.infer diff --git a/api/src/types/app.types.ts b/api/src/types/app.types.ts index c7c97dd..7d2fd55 100644 --- a/api/src/types/app.types.ts +++ b/api/src/types/app.types.ts @@ -23,6 +23,12 @@ 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(), + + // Comma-separated list of enabled login methods (magic-link, password, sso, social); unset = all enabled + AUTH_LOGIN_METHODS: z.string().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..af83ba7 100644 --- a/api/src/types/auth.types.ts +++ b/api/src/types/auth.types.ts @@ -62,6 +62,61 @@ 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 type LoginMethod = 'magic-link' | 'password' | 'sso' | 'social'; + +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/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). diff --git a/taskview-packages/taskview-api/src/api/ui-preferences.types.ts b/taskview-packages/taskview-api/src/api/ui-preferences.types.ts index b982f1e..4bb6adf 100644 --- a/taskview-packages/taskview-api/src/api/ui-preferences.types.ts +++ b/taskview-packages/taskview-api/src/api/ui-preferences.types.ts @@ -7,8 +7,12 @@ export type UiPreferencesItem = { export type FirstDayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6 +export type DefaultView = 'tasks' | 'kanban' | 'graph' | 'sprints' + export type UiSettings = { firstDayOfWeek?: FirstDayOfWeek + defaultProjectId?: number + defaultView?: DefaultView } export const UI_SETTINGS_KEY = '__settings__' diff --git a/web/docker-runtime-config.sh b/web/docker-runtime-config.sh new file mode 100755 index 0000000..1930268 --- /dev/null +++ b/web/docker-runtime-config.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Generates runtime config for the SPA from environment variables. +# Runs automatically at container start (nginx docker-entrypoint.d). +set -e + +if [ -n "${TASKVIEW_API_URL:-}" ]; then + echo "window.__TASKVIEW_CONFIG__ = { apiUrl: \"${TASKVIEW_API_URL}\" };" > /usr/share/nginx/html/config.js + echo "TaskView runtime config: apiUrl=${TASKVIEW_API_URL}" +fi diff --git a/web/dockerfile b/web/dockerfile index 335fcb5..a2c716e 100644 --- a/web/dockerfile +++ b/web/dockerfile @@ -2,6 +2,8 @@ FROM nginx:alpine COPY dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY docker-runtime-config.sh /docker-entrypoint.d/40-taskview-runtime-config.sh +RUN chmod +x /docker-entrypoint.d/40-taskview-runtime-config.sh EXPOSE 80 diff --git a/web/index.html b/web/index.html index 575b875..6ba26de 100644 --- a/web/index.html +++ b/web/index.html @@ -13,6 +13,7 @@ TaskView + 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/nginx.conf b/web/nginx.conf index 2145dfa..85451e0 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -30,6 +30,11 @@ server { add_header Cache-Control "no-cache, no-store, must-revalidate"; } + # Runtime config generated from env at container start — never cache + location = /config.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + error_page 404 /index.html; } diff --git a/web/public/config.js b/web/public/config.js new file mode 100644 index 0000000..e5ce2da --- /dev/null +++ b/web/public/config.js @@ -0,0 +1,3 @@ +// Runtime configuration. On self-hosted Docker deployments this file is +// overwritten at container start from the TASKVIEW_API_URL environment variable. +window.__TASKVIEW_CONFIG__ = {} 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/components/features/ui-customization/UiCustomizationOthers.vue b/web/src/components/features/ui-customization/UiCustomizationOthers.vue index 8aac546..c4cc179 100644 --- a/web/src/components/features/ui-customization/UiCustomizationOthers.vue +++ b/web/src/components/features/ui-customization/UiCustomizationOthers.vue @@ -14,19 +14,84 @@ :ui="{ base: 'rounded-xl' }" /> + + + + + + + +

diff --git a/web/src/composables/useAdditionalServer.ts b/web/src/composables/useAdditionalServer.ts index e8c871a..29ea869 100644 --- a/web/src/composables/useAdditionalServer.ts +++ b/web/src/composables/useAdditionalServer.ts @@ -3,6 +3,7 @@ import { ref } from 'vue' import $api from '@/helpers/axios' import { $ls, $tvApi } from '@/plugins/axios' import { additionalUrlStore } from '@/stores/additional-url.store' +import { getConfiguredApiUrl } from '@/helpers/serverConfig' export const LS_KEY_ADDITIONAL_SERVERS = 'additionalServers' export const LS_KEY_MAIN_SERVER = 'mainServer' @@ -17,15 +18,18 @@ export const useAdditionalServer = async () => { const { allServers, mainServer, systemServer } = storeToRefs(additionalUrlStore()) const serversFromLocalStorage = ref(await $ls.getValue(LS_KEY_ADDITIONAL_SERVERS)) const mainServerFromLocalStorage = await $ls.getValue(LS_KEY_MAIN_SERVER) + const configuredApiUrl = getConfiguredApiUrl() allServers.value = serversFromLocalStorage.value ? [...JSON.parse(serversFromLocalStorage.value)] : [] mainServer.value = + configuredApiUrl || mainServerFromLocalStorage || (process.env.NODE_ENV !== 'production' ? 'http://localhost:1401' : 'https://api.taskview.tech') systemServer.value = - process.env.NODE_ENV !== 'production' ? 'http://localhost:1401' : 'https://api.taskview.tech' + configuredApiUrl || + (process.env.NODE_ENV !== 'production' ? 'http://localhost:1401' : 'https://api.taskview.tech') const setMainServer = (server: string) => { mainServer.value = server diff --git a/web/src/composables/useTaskViewMainUrl.ts b/web/src/composables/useTaskViewMainUrl.ts index 24bd92a..f123e9b 100644 --- a/web/src/composables/useTaskViewMainUrl.ts +++ b/web/src/composables/useTaskViewMainUrl.ts @@ -1,9 +1,16 @@ +import { getConfiguredApiUrl } from '@/helpers/serverConfig' + /** * Use this composition to get OFFICIAL SERVER URL * We allow updates only from our servers * @returns */ export const useTaskViewMainUrl = () => { + // Self-hosted deployments pin the API URL at deploy time (config.js generated from TASKVIEW_API_URL). + // Mobile builds ship the empty config stub, so the updater below always talks to the official server. + const configuredUrl = getConfiguredApiUrl() + if (configuredUrl) return configuredUrl + // DO NOT CHANGE THIS URL WE ALLOW UPDATES ONLY FROM THIS OUR SERVERS return process.env.NODE_ENV !== 'production' ? 'http://localhost:1401' : 'https://api.taskview.tech' } diff --git a/web/src/helpers/serverConfig.ts b/web/src/helpers/serverConfig.ts new file mode 100644 index 0000000..4ea4501 --- /dev/null +++ b/web/src/helpers/serverConfig.ts @@ -0,0 +1,14 @@ +type TaskViewRuntimeConfig = { + apiUrl?: string +} + +declare global { + interface Window { + __TASKVIEW_CONFIG__?: TaskViewRuntimeConfig + } +} + +export const getConfiguredApiUrl = (): string | null => { + const url = window.__TASKVIEW_CONFIG__?.apiUrl?.trim() + return url ? url.replace(/\/+$/, '') : null +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index c0d52c2..badbc42 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -530,6 +530,8 @@ export default { subtasks: 'Unteraufgaben', addSubtask: 'Unteraufgabe hinzufügen', notFound: 'Aufgabe nicht gefunden', + copyId: 'Aufgaben-ID kopieren', + idCopied: 'Aufgaben-ID kopiert', noTasks: 'Keine Aufgaben', showCompleted: 'Abgeschlossene anzeigen', hideCompleted: 'Abgeschlossene ausblenden', @@ -703,6 +705,33 @@ export default { cancel: 'Abbrechen', deleted: 'Konto gelöscht', codeSendError: 'Code konnte nicht gesendet werden. Bitte wenden Sie sich an den Support, um Ihr Konto zu löschen.', + password: 'Passwort', + passwordDescription: 'Legen Sie das Passwort für die Anmeldung per E-Mail fest oder ändern Sie es. Wir senden einen Bestätigungscode an Ihre E-Mail.', + passwordDescriptionByPassword: 'Ändern Sie Ihr Anmeldepasswort. Bestätigen Sie die Änderung mit Ihrem aktuellen Passwort.', + sendPasswordCode: 'Bestätigungscode senden', + confirmPasswordChange: 'Passwortänderung bestätigen', + passwordCodeSent: 'Wir haben einen Bestätigungscode an Ihre E-Mail gesendet. Geben Sie ihn unten ein, um das neue Passwort festzulegen.', + changePassword: 'Passwort ändern', + passwordChanged: 'Passwort aktualisiert', + passwordChangeError: 'Passwort konnte nicht aktualisiert werden. Prüfen Sie den Code und versuchen Sie es erneut.', + passwordCodeCooldown: 'Code wurde bereits gesendet. Versuchen Sie es in einer Minute erneut.', + passwordCodeSendError: 'Code konnte nicht gesendet werden. Versuchen Sie es später erneut.', + defaultCredsTitle: 'Login und E-Mail', + defaultCredsWarning: 'Die Standard-Zugangsdaten sind öffentlich bekannt', + defaultCredsDescription: 'Ihr Konto verwendet die vorinstallierten Zugangsdaten. Legen Sie eigenen Login, E-Mail und Passwort fest — nach dem Speichern müssen Sie sich erneut anmelden.', + newLogin: 'Neuer Login', + newLoginPlaceholder: 'Buchstaben, Ziffern, . _ -', + newEmail: 'Neue E-Mail', + currentPassword: 'Aktuelles Passwort', + currentPasswordPlaceholder: 'Aktuelles Passwort eingeben', + currentPasswordRequired: 'Geben Sie Ihr aktuelles Passwort ein', + loginInvalid: 'Der Login muss 3-64 Zeichen lang sein', + saveCredentials: 'Speichern und abmelden', + credentialsChanged: 'Zugangsdaten aktualisiert. Melden Sie sich mit dem neuen Login und Passwort an.', + credentialsChangeError: 'Zugangsdaten konnten nicht aktualisiert werden. Versuchen Sie es erneut.', + wrongPassword: 'Falsches Passwort', + loginTaken: 'Dieser Login ist bereits vergeben', + emailTaken: 'Diese E-Mail ist bereits vergeben', }, organizations: { title: 'Organisationen', @@ -763,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 3e7e30a..01e5ef8 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', @@ -717,6 +719,33 @@ export default { cancel: 'Cancel', deleted: 'Account deleted', codeSendError: 'Unable to send the code. Please contact support to delete your account.', + password: 'Password', + passwordDescription: 'Set or change the password you use to sign in with your email. We will send a confirmation code to your email.', + passwordDescriptionByPassword: 'Change the password you use to sign in. Confirm the change with your current password.', + sendPasswordCode: 'Send confirmation code', + confirmPasswordChange: 'Confirm password change', + passwordCodeSent: 'We sent a confirmation code to your email. Enter it below to set the new password.', + changePassword: 'Change password', + passwordChanged: 'Password updated', + passwordChangeError: 'Could not update the password. Check the code and try again.', + passwordCodeCooldown: 'Code already sent. Try again in a minute.', + passwordCodeSendError: 'Unable to send the code. Try again later.', + defaultCredsTitle: 'Login and email', + defaultCredsWarning: 'The default sign-in credentials are publicly known', + defaultCredsDescription: 'Your account uses the preinstalled credentials. Set your own login, email and password — you will need to sign in again after saving.', + newLogin: 'New login', + newLoginPlaceholder: 'Letters, digits, . _ -', + newEmail: 'New email', + currentPassword: 'Current password', + currentPasswordPlaceholder: 'Enter current password', + currentPasswordRequired: 'Enter your current password', + loginInvalid: 'Login must be 3-64 characters', + saveCredentials: 'Save and sign out', + credentialsChanged: 'Credentials updated. Sign in with the new login and password.', + credentialsChangeError: 'Could not update credentials. Try again.', + wrongPassword: 'Wrong password', + loginTaken: 'This login is already taken', + emailTaken: 'This email is already taken', }, organizations: { title: 'Organizations', @@ -777,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 d3ffbbc..3839029 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -530,6 +530,8 @@ export default { subtasks: 'Subtareas', addSubtask: 'Añadir subtarea', notFound: 'Tarea no encontrada', + copyId: 'Copiar ID de la tarea', + idCopied: 'ID de la tarea copiado', noTasks: 'No hay tareas', showCompleted: 'Mostrar completadas', hideCompleted: 'Ocultar completadas', @@ -703,6 +705,33 @@ export default { cancel: 'Cancelar', deleted: 'Cuenta eliminada', codeSendError: 'No se pudo enviar el código. Ponte en contacto con soporte para eliminar tu cuenta.', + password: 'Contraseña', + passwordDescription: 'Establece o cambia la contraseña para iniciar sesión con tu email. Enviaremos un código de confirmación a tu correo.', + passwordDescriptionByPassword: 'Cambia la contraseña que usas para iniciar sesión. Confirma el cambio con tu contraseña actual.', + sendPasswordCode: 'Enviar código de confirmación', + confirmPasswordChange: 'Confirmar cambio de contraseña', + passwordCodeSent: 'Hemos enviado un código de confirmación a tu correo. Introdúcelo abajo para establecer la nueva contraseña.', + changePassword: 'Cambiar contraseña', + passwordChanged: 'Contraseña actualizada', + passwordChangeError: 'No se pudo actualizar la contraseña. Comprueba el código e inténtalo de nuevo.', + passwordCodeCooldown: 'El código ya fue enviado. Inténtalo de nuevo en un minuto.', + passwordCodeSendError: 'No se pudo enviar el código. Inténtalo más tarde.', + defaultCredsTitle: 'Login y email', + defaultCredsWarning: 'Las credenciales de acceso predeterminadas son de conocimiento público', + defaultCredsDescription: 'Tu cuenta usa las credenciales preinstaladas. Establece tu propio login, email y contraseña — tendrás que iniciar sesión de nuevo después de guardar.', + newLogin: 'Nuevo login', + newLoginPlaceholder: 'Letras, dígitos, . _ -', + newEmail: 'Nuevo email', + currentPassword: 'Contraseña actual', + currentPasswordPlaceholder: 'Introduce la contraseña actual', + currentPasswordRequired: 'Introduce tu contraseña actual', + loginInvalid: 'El login debe tener entre 3 y 64 caracteres', + saveCredentials: 'Guardar y cerrar sesión', + credentialsChanged: 'Credenciales actualizadas. Inicia sesión con el nuevo login y contraseña.', + credentialsChangeError: 'No se pudieron actualizar las credenciales. Inténtalo de nuevo.', + wrongPassword: 'Contraseña incorrecta', + loginTaken: 'Este login ya está en uso', + emailTaken: 'Este email ya está en uso', }, organizations: { title: 'Organizaciones', @@ -763,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 2d6324d..4bbfbd9 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: 'Скрыть выполненные', @@ -690,6 +692,33 @@ export default { cancel: 'Отмена', deleted: 'Аккаунт удалён', codeSendError: 'Не удалось отправить код. Обратитесь в поддержку для удаления аккаунта.', + password: 'Пароль', + passwordDescription: 'Задайте или измените пароль для входа по email. Мы отправим код подтверждения на вашу почту.', + passwordDescriptionByPassword: 'Измените пароль для входа. Подтвердите изменение текущим паролем.', + sendPasswordCode: 'Отправить код подтверждения', + confirmPasswordChange: 'Подтверждение смены пароля', + passwordCodeSent: 'Мы отправили код подтверждения на вашу почту. Введите его ниже, чтобы установить новый пароль.', + changePassword: 'Изменить пароль', + passwordChanged: 'Пароль обновлён', + passwordChangeError: 'Не удалось обновить пароль. Проверьте код и попробуйте ещё раз.', + passwordCodeCooldown: 'Код уже отправлен. Повторите через минуту.', + passwordCodeSendError: 'Не удалось отправить код. Попробуйте позже.', + defaultCredsTitle: 'Логин и email', + defaultCredsWarning: 'Стандартные данные для входа общеизвестны', + defaultCredsDescription: 'Учётная запись использует стандартные данные. Задайте свои логин, email и пароль — после сохранения потребуется войти заново.', + newLogin: 'Новый логин', + newLoginPlaceholder: 'Буквы, цифры, . _ -', + newEmail: 'Новый email', + currentPassword: 'Текущий пароль', + currentPasswordPlaceholder: 'Введите текущий пароль', + currentPasswordRequired: 'Введите текущий пароль', + loginInvalid: 'Логин должен быть от 3 до 64 символов', + saveCredentials: 'Сохранить и выйти', + credentialsChanged: 'Данные обновлены. Войдите с новым логином и паролем.', + credentialsChangeError: 'Не удалось обновить данные. Попробуйте ещё раз.', + wrongPassword: 'Неверный пароль', + loginTaken: 'Этот логин уже занят', + emailTaken: 'Этот email уже занят', }, organizations: { title: 'Организации', @@ -750,6 +779,15 @@ export default { friday: 'Пятница', saturday: 'Суббота', sunday: 'Воскресенье', + defaultProject: 'Проект по умолчанию', + defaultProjectHint: 'Открывать этот проект сразу после входа.', + defaultProjectNone: 'Главный экран (по умолчанию)', + defaultView: 'Вид по умолчанию', + defaultViewHint: 'Какой вид проекта открывать.', + viewTasks: 'Задачи', + viewKanban: 'Канбан', + viewGraph: 'Граф', + viewSprints: 'Спринты', }, taskFields: { subtasks: 'Подзадачи', diff --git a/web/src/plugins/axios.ts b/web/src/plugins/axios.ts index a5afc28..9d72253 100644 --- a/web/src/plugins/axios.ts +++ b/web/src/plugins/axios.ts @@ -4,6 +4,7 @@ import type { App } from 'vue' import $api from '@/helpers/axios' import LocalStorage from '@/helpers/LocalStorage' import { LS_KEY_MAIN_SERVER } from '@/composables/useAdditionalServer' +import { getConfiguredApiUrl } from '@/helpers/serverConfig' let $ls: LocalStorage const $tvApi: TvApi = new TvApi($api) @@ -19,8 +20,8 @@ const api = { $ls = app.config.globalProperties.$ls - const savedServer = await $ls.getValue(LS_KEY_MAIN_SERVER) - console.log('savedServer', savedServer, LS_KEY_MAIN_SERVER) + // A deploy-time API URL (config.js) always wins over a server saved in local storage + const savedServer = getConfiguredApiUrl() ? null : await $ls.getValue(LS_KEY_MAIN_SERVER) if (savedServer) { $api.defaults.baseURL = savedServer $api.defaults.headers.common['ngrok-skip-browser-warning'] = '1' 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), }