mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Merge pull request #81 from Gimanh/fix/72-ui-and-setup
Fix/72 UI and setup
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<UpdateUserCredentialsResult> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<LoginMethod> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TaskItemForClient[]> {
|
||||
if (goalsIds.length === 0 || !description.trim()) {
|
||||
async searchTask(args: SearchTaskArgs): Promise<SearchTaskResult[]> {
|
||||
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<TaskItemInDb>(
|
||||
`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<number, TaskItemForClient> = 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: [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -62,6 +62,61 @@ export const ChangePasswordDataScheme = z
|
||||
|
||||
export type ChangePasswordData = z.infer<typeof ChangePasswordDataScheme>;
|
||||
|
||||
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<typeof ChangeOwnPasswordSchema>;
|
||||
|
||||
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<typeof ChangeOwnPasswordByPasswordSchema>;
|
||||
|
||||
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<typeof ChangeDefaultUserCredentialsSchema>;
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -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:
|
||||

|
||||
|
||||
```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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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=
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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__'
|
||||
|
||||
Executable
+9
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<link href="https://fonts.bunny.net/css?family=public-sans:400,500,600,700"
|
||||
rel="stylesheet" />
|
||||
<title>TaskView</title>
|
||||
<script src="/config.js"></script>
|
||||
<meta name="description"
|
||||
content="TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.">
|
||||
</head>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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__ = {}
|
||||
@@ -2,15 +2,39 @@
|
||||
<div class="flex flex-col gap-6 p-1 lg:p-6 w-full max-w-full lg:max-w-2xl m-0 lg:mx-auto">
|
||||
<NotificationSettings />
|
||||
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<UPageCard
|
||||
variant="soft"
|
||||
class="w-full rounded-3xl"
|
||||
>
|
||||
<SessionsPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<UPageCard
|
||||
variant="soft"
|
||||
class="w-full rounded-3xl"
|
||||
>
|
||||
<ApiTokensPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<UPageCard
|
||||
v-if="isDefaultUser"
|
||||
variant="soft"
|
||||
class="w-full rounded-3xl bg-warning/10 ring-2 ring-warning/60"
|
||||
>
|
||||
<DefaultUserCredentials />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard
|
||||
variant="soft"
|
||||
class="w-full rounded-3xl"
|
||||
>
|
||||
<PasswordSettings />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard
|
||||
variant="soft"
|
||||
class="w-full rounded-3xl"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('account.management') }}
|
||||
@@ -34,12 +58,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import DeleteAccountButton from './parts/DeleteAccountButton.vue'
|
||||
import DeleteAccountCodeModal from './parts/DeleteAccountCodeModal.vue'
|
||||
import NotificationSettings from './parts/NotificationSettings.vue'
|
||||
import PasswordSettings from './parts/PasswordSettings.vue'
|
||||
import DefaultUserCredentials from './parts/DefaultUserCredentials.vue'
|
||||
import SessionsPanel from '@/components/features/sessions/SessionsPanel.vue'
|
||||
import ApiTokensPanel from '@/components/features/api-tokens/ApiTokensPanel.vue'
|
||||
|
||||
@@ -49,6 +75,9 @@ const toast = useToast()
|
||||
|
||||
const showCodeModal = ref(false)
|
||||
|
||||
// Seeded default user on self-hosted installs (migration 0.0.0); backend enforces the same check
|
||||
const isDefaultUser = computed(() => userStore.email.toLowerCase() === 'test@mail.dest')
|
||||
|
||||
function onCodeSendError() {
|
||||
toast.add({
|
||||
title: t('account.codeSendError'),
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<UIcon
|
||||
name="i-lucide-triangle-alert"
|
||||
class="size-5 text-warning shrink-0"
|
||||
/>
|
||||
{{ t('account.defaultCredsTitle') }}
|
||||
</h2>
|
||||
<UAlert
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-shield-alert"
|
||||
:title="t('account.defaultCredsWarning')"
|
||||
:description="t('account.defaultCredsDescription')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UForm
|
||||
:state="state"
|
||||
:schema="CredentialsSchema"
|
||||
:validate="validatePasswordsMatch"
|
||||
class="space-y-4"
|
||||
@submit="save"
|
||||
>
|
||||
<UFormField
|
||||
:label="t('account.newLogin')"
|
||||
name="login"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.login"
|
||||
:placeholder="t('account.newLoginPlaceholder')"
|
||||
icon="i-lucide-user"
|
||||
autocomplete="username"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('account.newEmail')"
|
||||
name="email"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.email"
|
||||
type="email"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
icon="i-lucide-mail"
|
||||
autocomplete="email"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('auth.newPassword')"
|
||||
name="password"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.password"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
:placeholder="t('auth.newPasswordPlaceholder')"
|
||||
icon="i-lucide-lock"
|
||||
autocomplete="new-password"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="showNewPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('auth.confirmPassword')"
|
||||
name="passwordRepeat"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.passwordRepeat"
|
||||
:type="showNewPasswordRepeat ? 'text' : 'password'"
|
||||
:placeholder="t('auth.confirmPasswordPlaceholder')"
|
||||
icon="i-lucide-lock"
|
||||
autocomplete="new-password"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="showNewPasswordRepeat ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="showNewPasswordRepeat = !showNewPasswordRepeat"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('account.currentPassword')"
|
||||
name="currentPassword"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.currentPassword"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:placeholder="t('account.currentPasswordPlaceholder')"
|
||||
icon="i-lucide-lock"
|
||||
autocomplete="current-password"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<div>
|
||||
<UButton
|
||||
:label="t('account.saveCredentials')"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isSaving"
|
||||
/>
|
||||
</div>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type } from 'arktype'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import $api from '@/helpers/axios'
|
||||
import { $ls } from '@/plugins/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const showNewPassword = ref(false)
|
||||
const showNewPasswordRepeat = ref(false)
|
||||
|
||||
const CredentialsSchema = type({
|
||||
login: type('3 <= string <= 64').configure({ message: t('account.loginInvalid') }),
|
||||
email: type('string.email').configure({ message: t('auth.invalidEmail') }),
|
||||
password: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
|
||||
passwordRepeat: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
|
||||
currentPassword: type('string >= 1').configure({ message: t('account.currentPasswordRequired') }),
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
login: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordRepeat: '',
|
||||
currentPassword: '',
|
||||
})
|
||||
|
||||
function validatePasswordsMatch(formState: Partial<{ password: string; passwordRepeat: string }>) {
|
||||
const errors = []
|
||||
if (formState.password && formState.passwordRepeat && formState.password !== formState.passwordRepeat) {
|
||||
errors.push({ name: 'passwordRepeat', message: t('auth.passwordsDoNotMatch') })
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
async function save() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await $api.post<{ changed: boolean }>('/module/auth/credentials/change', {
|
||||
currentPassword: state.currentPassword,
|
||||
login: state.login.trim(),
|
||||
email: state.email.trim(),
|
||||
password: state.password,
|
||||
passwordRepeat: state.passwordRepeat,
|
||||
})
|
||||
if (result.data.changed) {
|
||||
toast.add({
|
||||
title: t('account.credentialsChanged'),
|
||||
color: 'success',
|
||||
})
|
||||
$ls.invalidateTokens()
|
||||
router.push('/')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: errorMessage(error),
|
||||
color: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (isAxiosError(error)) {
|
||||
const field = error.response?.data?.field
|
||||
if (error.response?.status === 403 && field === 'currentPassword') return t('account.wrongPassword')
|
||||
if (error.response?.status === 409 && field === 'login') return t('account.loginTaken')
|
||||
if (error.response?.status === 409 && field === 'email') return t('account.emailTaken')
|
||||
}
|
||||
return t('account.credentialsChangeError')
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<UPageCard
|
||||
variant="soft"
|
||||
class="w-full rounded-3xl"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:fullscreen="isMobile"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('account.confirmPasswordChange') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
{{ t('account.passwordCodeSent') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UInput
|
||||
v-model="code"
|
||||
:placeholder="t('account.enterCode')"
|
||||
spellcheck="false"
|
||||
autocomplete="one-time-code"
|
||||
variant="soft"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ t('account.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
:disabled="!code"
|
||||
:loading="isLoading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ t('account.changePassword') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import $api from '@/helpers/axios'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
password: string
|
||||
passwordRepeat: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const toast = useToast()
|
||||
|
||||
const code = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) code.value = ''
|
||||
})
|
||||
|
||||
async function handleConfirm() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await $api.post<{ changed: boolean }>('/module/auth/password/change', {
|
||||
code: code.value.trim(),
|
||||
password: props.password,
|
||||
passwordRepeat: props.passwordRepeat,
|
||||
})
|
||||
if (result.data.changed) {
|
||||
toast.add({
|
||||
title: t('account.passwordChanged'),
|
||||
color: 'success',
|
||||
})
|
||||
open.value = false
|
||||
emit('changed')
|
||||
} else {
|
||||
toast.add({
|
||||
title: t('account.passwordChangeError'),
|
||||
color: 'error',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.add({
|
||||
title: t('account.passwordChangeError'),
|
||||
color: 'error',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('account.password') }}
|
||||
</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
{{ byCurrentPassword ? t('account.passwordDescriptionByPassword') : t('account.passwordDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UForm
|
||||
:state="state"
|
||||
:schema="PasswordSchema"
|
||||
:validate="validatePasswordsMatch"
|
||||
class="space-y-4"
|
||||
@submit="sendCode"
|
||||
>
|
||||
<UFormField
|
||||
:label="t('auth.newPassword')"
|
||||
name="password"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:placeholder="t('auth.newPasswordPlaceholder')"
|
||||
icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('auth.confirmPassword')"
|
||||
name="passwordRepeat"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.passwordRepeat"
|
||||
:type="showPasswordRepeat ? 'text' : 'password'"
|
||||
:placeholder="t('auth.confirmPasswordPlaceholder')"
|
||||
icon="i-lucide-lock"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="showPasswordRepeat ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="showPasswordRepeat = !showPasswordRepeat"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
v-if="byCurrentPassword"
|
||||
:label="t('account.currentPassword')"
|
||||
name="currentPassword"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.currentPassword"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
:placeholder="t('account.currentPasswordPlaceholder')"
|
||||
icon="i-lucide-lock"
|
||||
autocomplete="current-password"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="showCurrentPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<div>
|
||||
<UButton
|
||||
:label="byCurrentPassword ? t('account.changePassword') : t('account.sendPasswordCode')"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isSending"
|
||||
/>
|
||||
</div>
|
||||
</UForm>
|
||||
|
||||
<PasswordCodeModal
|
||||
v-model:open="showCodeModal"
|
||||
:password="state.password"
|
||||
:password-repeat="state.passwordRepeat"
|
||||
@changed="onPasswordChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type } from 'arktype'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import $api from '@/helpers/axios'
|
||||
import { logError } from '@/helpers/Helper'
|
||||
import PasswordCodeModal from './PasswordCodeModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const isSending = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const showPasswordRepeat = ref(false)
|
||||
const showCurrentPassword = ref(false)
|
||||
const showCodeModal = ref(false)
|
||||
|
||||
const confirmationMode = ref<'email' | 'password'>('email')
|
||||
const byCurrentPassword = computed(() => confirmationMode.value === 'password')
|
||||
|
||||
onMounted(async () => {
|
||||
const result = await $api
|
||||
.get<{ mode: 'email' | 'password' }>('/module/auth/password/change/mode')
|
||||
.catch(logError)
|
||||
if (result) confirmationMode.value = result.data.mode
|
||||
})
|
||||
|
||||
const PasswordSchema = type({
|
||||
password: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
|
||||
passwordRepeat: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
password: '',
|
||||
passwordRepeat: '',
|
||||
currentPassword: '',
|
||||
})
|
||||
|
||||
function validatePasswordsMatch(formState: Partial<{ password: string; passwordRepeat: string; currentPassword: string }>) {
|
||||
const errors = []
|
||||
if (formState.password && formState.passwordRepeat && formState.password !== formState.passwordRepeat) {
|
||||
errors.push({ name: 'passwordRepeat', message: t('auth.passwordsDoNotMatch') })
|
||||
}
|
||||
if (byCurrentPassword.value && !formState.currentPassword) {
|
||||
errors.push({ name: 'currentPassword', message: t('account.currentPasswordRequired') })
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
if (byCurrentPassword.value) {
|
||||
await changeByCurrentPassword()
|
||||
return
|
||||
}
|
||||
|
||||
isSending.value = true
|
||||
try {
|
||||
await $api.post('/module/auth/password/change/code')
|
||||
showCodeModal.value = true
|
||||
} catch (error) {
|
||||
const isCooldown = isAxiosError(error) && error.response?.status === 429
|
||||
toast.add({
|
||||
title: isCooldown ? t('account.passwordCodeCooldown') : t('account.passwordCodeSendError'),
|
||||
color: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeByCurrentPassword() {
|
||||
isSending.value = true
|
||||
try {
|
||||
const result = await $api.post<{ changed: boolean }>('/module/auth/password/change', {
|
||||
currentPassword: state.currentPassword,
|
||||
password: state.password,
|
||||
passwordRepeat: state.passwordRepeat,
|
||||
})
|
||||
if (result.data.changed) {
|
||||
toast.add({
|
||||
title: t('account.passwordChanged'),
|
||||
color: 'success',
|
||||
})
|
||||
onPasswordChanged()
|
||||
}
|
||||
} catch (error) {
|
||||
const isWrongPassword = isAxiosError(error) && error.response?.status === 403
|
||||
toast.add({
|
||||
title: isWrongPassword ? t('account.wrongPassword') : t('account.passwordChangeError'),
|
||||
color: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPasswordChanged() {
|
||||
state.password = ''
|
||||
state.passwordRepeat = ''
|
||||
state.currentPassword = ''
|
||||
showPassword.value = false
|
||||
showPasswordRepeat.value = false
|
||||
showCurrentPassword.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -61,7 +61,7 @@
|
||||
<UButton
|
||||
:label="t('common.delete')"
|
||||
color="error"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
:loading="deleting"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
|
||||
@@ -19,11 +19,26 @@
|
||||
</template>
|
||||
|
||||
<!-- Login Views -->
|
||||
<template v-else-if="isLoadingOptions">
|
||||
<div class="flex justify-center py-10">
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-6 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SocialButtons />
|
||||
<SocialButtons
|
||||
v-if="loginOptions.socialProviders.length > 0"
|
||||
:providers="loginOptions.socialProviders"
|
||||
/>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="loginOptions.socialProviders.length > 0 && tabs.length > 0"
|
||||
class="relative"
|
||||
>
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-default" />
|
||||
</div>
|
||||
@@ -32,8 +47,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single method — no tabs needed -->
|
||||
<template v-if="tabs.length === 1">
|
||||
<LoginByCode
|
||||
v-if="tabs[0].value === 'code'"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
<LoginByPassword
|
||||
v-else-if="tabs[0].value === 'password'"
|
||||
@success="handleSuccess"
|
||||
@forgot-password="currentView = 'forgot'"
|
||||
/>
|
||||
<LoginBySso v-else-if="tabs[0].value === 'sso'" />
|
||||
</template>
|
||||
|
||||
<!-- Tabs -->
|
||||
<UTabs
|
||||
v-else-if="tabs.length > 1"
|
||||
v-model="currentView"
|
||||
:items="tabs"
|
||||
class="w-full"
|
||||
@@ -62,8 +92,11 @@
|
||||
</UTabs>
|
||||
</template>
|
||||
|
||||
<!-- Server Selector -->
|
||||
<UCollapsible class="flex flex-col gap-2">
|
||||
<!-- Server Selector (hidden when the API URL is pinned at deploy time) -->
|
||||
<UCollapsible
|
||||
v-if="!isServerLocked"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<UButton
|
||||
class="group"
|
||||
:label="t('server.selectServer')"
|
||||
@@ -99,8 +132,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import $api from '@/helpers/axios'
|
||||
import { logError } from '@/helpers/Helper'
|
||||
import { getConfiguredApiUrl } from '@/helpers/serverConfig'
|
||||
import LoginByCode from './LoginByCode.vue'
|
||||
import LoginByPassword from './LoginByPassword.vue'
|
||||
import LoginBySso from './LoginBySso.vue'
|
||||
@@ -116,13 +152,48 @@ const emit = defineEmits<{
|
||||
|
||||
type View = 'code' | 'password' | 'sso' | 'forgot'
|
||||
|
||||
type LoginOptions = {
|
||||
magicLink: boolean
|
||||
password: boolean
|
||||
sso: boolean
|
||||
socialProviders: string[]
|
||||
}
|
||||
|
||||
const currentView = ref<View>('code')
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ value: 'code', label: t('auth.magicLink'), slot: 'code' as const },
|
||||
{ value: 'password', label: t('auth.password'), slot: 'password' as const },
|
||||
{ value: 'sso', label: 'SSO', slot: 'sso' as const },
|
||||
])
|
||||
const isLoadingOptions = ref(true)
|
||||
const isServerLocked = getConfiguredApiUrl() !== null
|
||||
|
||||
const loginOptions = reactive<LoginOptions>({
|
||||
magicLink: true,
|
||||
password: true,
|
||||
sso: true,
|
||||
socialProviders: ['google', 'github', 'apple'],
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await $api.get<LoginOptions>('/module/auth/login-options').catch(logError)
|
||||
if (result) Object.assign(loginOptions, result.data)
|
||||
} finally {
|
||||
isLoadingOptions.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
const items = []
|
||||
if (loginOptions.magicLink) items.push({ value: 'code', label: t('auth.magicLink'), slot: 'code' as const })
|
||||
if (loginOptions.password) items.push({ value: 'password', label: t('auth.password'), slot: 'password' as const })
|
||||
if (loginOptions.sso) items.push({ value: 'sso', label: 'SSO', slot: 'sso' as const })
|
||||
return items
|
||||
})
|
||||
|
||||
watch(tabs, (items) => {
|
||||
if (currentView.value === 'forgot') return
|
||||
if (!items.some((item) => item.value === currentView.value)) {
|
||||
currentView.value = (items[0]?.value ?? 'code') as View
|
||||
}
|
||||
})
|
||||
|
||||
function onTabChange(value: string | number) {
|
||||
currentView.value = value as View
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<UButton
|
||||
v-if="providers.includes('google')"
|
||||
:label="t('auth.continueWithGoogle')"
|
||||
icon="i-lucide-chrome"
|
||||
color="neutral"
|
||||
@@ -11,6 +12,7 @@
|
||||
@click="handleLogin('google')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="providers.includes('github')"
|
||||
:label="t('auth.continueWithGithub')"
|
||||
icon="i-lucide-github"
|
||||
color="neutral"
|
||||
@@ -21,6 +23,7 @@
|
||||
@click="handleLogin('github')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="providers.includes('apple')"
|
||||
:label="t('auth.continueWithApple')"
|
||||
icon="i-lucide-apple"
|
||||
color="neutral"
|
||||
@@ -42,6 +45,10 @@ import { useAdditionalServer } from '@/composables/useAdditionalServer'
|
||||
|
||||
type Provider = 'google' | 'github' | 'apple'
|
||||
|
||||
defineProps<{
|
||||
providers: string[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref<Provider | null>(null)
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
import { ALL_TASKS_LIST_ID, type DefaultView } from 'taskview-api'
|
||||
import type { RouteLocationRaw, Router } from 'vue-router'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { Router } from 'vue-router'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
|
||||
const VIEW_ROUTES: Record<DefaultView, string> = {
|
||||
tasks: 'user',
|
||||
kanban: 'kanban',
|
||||
graph: 'graph',
|
||||
sprints: 'sprints',
|
||||
}
|
||||
|
||||
export const redirectToUser = async (router: Router) => {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.accessToken) {
|
||||
const orgStore = useOrganizationStore()
|
||||
if (!orgStore.organizations.length) {
|
||||
await orgStore.fetchOrganizations()
|
||||
orgStore.restoreCurrentOrg()
|
||||
}
|
||||
await router.push({ name: 'user', params: { orgSlug: orgStore.currentOrgSlug } })
|
||||
if (!userStore.accessToken) return
|
||||
|
||||
const orgStore = useOrganizationStore()
|
||||
if (!orgStore.organizations.length) {
|
||||
await orgStore.fetchOrganizations()
|
||||
orgStore.restoreCurrentOrg()
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRoute = await resolveDefaultRoute()
|
||||
await router.push(defaultRoute ?? { name: 'user', params: { orgSlug: orgStore.currentOrgSlug } })
|
||||
}
|
||||
|
||||
export const resolveDefaultRoute = async (): Promise<RouteLocationRaw | null> => {
|
||||
const uiPrefs = useUiPreferencesStore()
|
||||
if (!uiPrefs.loaded) await uiPrefs.fetch()
|
||||
|
||||
const projectId = uiPrefs.settings.defaultProjectId
|
||||
if (!projectId) return null
|
||||
|
||||
// The default project may live in any of the user's organizations; try the current one first
|
||||
const orgStore = useOrganizationStore()
|
||||
const orgs = [...orgStore.organizations].sort((a) => (a.slug === orgStore.currentOrgSlug ? -1 : 1))
|
||||
|
||||
for (const org of orgs) {
|
||||
const goals = await $tvApi.goals.fetchGoals(org.id)
|
||||
if (!goals?.some((goal) => goal.id === projectId)) continue
|
||||
|
||||
const view = uiPrefs.settings.defaultView ?? 'tasks'
|
||||
const params: Record<string, string | number> = { orgSlug: org.slug, projectId }
|
||||
if (view === 'tasks') params.listId = ALL_TASKS_LIST_ID
|
||||
return { name: VIEW_ROUTES[view], params }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
</UTextarea>
|
||||
</div>
|
||||
</div>
|
||||
<TaskIdCopy
|
||||
:task-id="task.id"
|
||||
class="self-start -mt-2"
|
||||
/>
|
||||
<!-- Source link (GitHub/GitLab issue) -->
|
||||
<a
|
||||
v-if="task.sourceUrl"
|
||||
@@ -170,6 +174,7 @@ import TaskHistory from '@/components/features/tasks/parts/TaskHistory.vue'
|
||||
import TvDeadlineSelect from '@/components/features/base/TvDeadlineSelect.vue'
|
||||
import TaskRecurrence from '@/components/features/tasks/parts/TaskRecurrence.vue'
|
||||
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
|
||||
import TaskIdCopy from '@/components/features/tasks/parts/TaskIdCopy.vue'
|
||||
import TvSprintSelect from '@/components/features/base/TvSprintSelect.vue'
|
||||
import TaskEstimateInput from '@/components/features/tasks/parts/TaskEstimateInput.vue'
|
||||
import type { PriorityValue } from '@/composables/usePriorityOptions'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<UButton
|
||||
variant="link"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-hash'"
|
||||
class="text-muted hover:text-default"
|
||||
:title="t('tasks.copyId')"
|
||||
@click="copyId"
|
||||
>
|
||||
{{ taskId }}
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
async function copyId() {
|
||||
await copy(`#${props.taskId}`)
|
||||
toast.add({
|
||||
title: t('tasks.idCopied'),
|
||||
color: 'success',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -14,19 +14,84 @@
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('uiCustomization.others.defaultProject')"
|
||||
:description="t('uiCustomization.others.defaultProjectHint')"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="defaultProject"
|
||||
:items="projectItems"
|
||||
value-key="value"
|
||||
variant="soft"
|
||||
class="w-full lg:w-72"
|
||||
size="xl"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="t('uiCustomization.others.defaultView')"
|
||||
:description="t('uiCustomization.others.defaultViewHint')"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="defaultView"
|
||||
:items="viewItems"
|
||||
value-key="value"
|
||||
:disabled="defaultProject === NONE"
|
||||
variant="soft"
|
||||
class="w-full lg:w-72"
|
||||
size="xl"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { FirstDayOfWeek } from 'taskview-api'
|
||||
import type { DefaultView, FirstDayOfWeek } from 'taskview-api'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useUiPreferencesStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const DEFAULT = -1
|
||||
const NONE = -1
|
||||
|
||||
onMounted(() => {
|
||||
if (!goalsStore.initialized) goalsStore.fetchGoals()
|
||||
})
|
||||
|
||||
const defaultProject = computed<number>({
|
||||
get: () => store.settings.defaultProjectId ?? NONE,
|
||||
set: (value) => {
|
||||
store.setSetting('defaultProjectId', value === NONE ? undefined : value)
|
||||
if (value === NONE) store.setSetting('defaultView', undefined)
|
||||
},
|
||||
})
|
||||
|
||||
const defaultView = computed<DefaultView>({
|
||||
get: () => store.settings.defaultView ?? 'tasks',
|
||||
set: (value) => {
|
||||
store.setSetting('defaultView', value === 'tasks' ? undefined : value)
|
||||
},
|
||||
})
|
||||
|
||||
const projectItems = computed(() => [
|
||||
{ value: NONE, label: t('uiCustomization.others.defaultProjectNone') },
|
||||
...goalsStore.goals.map((goal) => ({ value: goal.id, label: goal.name })),
|
||||
])
|
||||
|
||||
const viewItems = computed(() => [
|
||||
{ value: 'tasks', label: t('uiCustomization.others.viewTasks') },
|
||||
{ value: 'kanban', label: t('uiCustomization.others.viewKanban') },
|
||||
{ value: 'graph', label: t('uiCustomization.others.viewGraph') },
|
||||
{ value: 'sprints', label: t('uiCustomization.others.viewSprints') },
|
||||
])
|
||||
|
||||
const weekStart = computed<number>({
|
||||
get: () => store.settings.firstDayOfWeek ?? DEFAULT,
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
<SidebarInboxLink />
|
||||
<div class="flex items-stretch gap-2">
|
||||
<SidebarInboxLink class="flex-1" />
|
||||
<SidebarDefaultProjectLink />
|
||||
</div>
|
||||
<SidebarWorkspaceLinks />
|
||||
|
||||
<USeparator />
|
||||
@@ -38,6 +41,7 @@ import { useDashboard } from '@/composables/useDashboard'
|
||||
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
|
||||
import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
|
||||
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
|
||||
import SidebarDefaultProjectLink from '@/components/sidebars/SidebarDefaultProjectLink.vue'
|
||||
import SidebarWorkspaceLinks from '@/components/sidebars/SidebarWorkspaceLinks.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
</div>
|
||||
|
||||
<SearchActivator />
|
||||
<SidebarInboxLink />
|
||||
<div class="flex items-stretch gap-2">
|
||||
<SidebarInboxLink class="flex-1" />
|
||||
<SidebarDefaultProjectLink />
|
||||
</div>
|
||||
|
||||
<USeparator />
|
||||
|
||||
@@ -58,6 +61,7 @@ import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
|
||||
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
|
||||
import SidebarDefaultProjectLink from '@/components/sidebars/SidebarDefaultProjectLink.vue'
|
||||
import SidebarWorkspaceLinks from '@/components/sidebars/SidebarWorkspaceLinks.vue'
|
||||
import SidebarProjectSelect from './dashboard-second/SidebarProjectSelect.vue'
|
||||
import SidebarTools from './dashboard-second/SidebarTools.vue'
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<UTooltip
|
||||
v-if="defaultProjectId"
|
||||
:text="label"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-target"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:aria-label="label"
|
||||
class="rounded-xl shadow-sm aspect-square justify-center h-auto"
|
||||
:class="{ 'bg-primary/10 text-primary': currentProjectId === defaultProjectId }"
|
||||
@click="openDefaultProject"
|
||||
/>
|
||||
</UTooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { resolveDefaultRoute } from '@/components/features/auth/auth.helper'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const uiPrefs = useUiPreferencesStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
onMounted(() => {
|
||||
if (!uiPrefs.loaded) uiPrefs.fetch()
|
||||
})
|
||||
|
||||
const defaultProjectId = computed(() => uiPrefs.settings.defaultProjectId ?? null)
|
||||
const currentProjectId = computed(() => Number(route.params.projectId) || null)
|
||||
|
||||
const label = computed(() => {
|
||||
const id = defaultProjectId.value
|
||||
const name = id !== null ? goalsStore.goalMap.get(id)?.name : undefined
|
||||
return name ?? t('uiCustomization.others.defaultProject')
|
||||
})
|
||||
|
||||
async function openDefaultProject() {
|
||||
const target = await resolveDefaultRoute()
|
||||
if (target) await router.push(target)
|
||||
}
|
||||
</script>
|
||||
@@ -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<string | null>(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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Подзадачи',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user