mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-12 05:49:01 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c990aff4f | |||
| 40cd8064f5 | |||
| f4a765f40b | |||
| b9ce7261f0 | |||
| bc08c839bc | |||
| 83979ac47c | |||
| a9be0d987a | |||
| 30274069a0 | |||
| 0a8497af59 | |||
| 284a49ce8c | |||
| 64a227303e | |||
| ae88d0f42a | |||
| d40cc0aa1b | |||
| e99d9d5515 | |||
| 9b38e3cd9d | |||
| 9c6d33cefe | |||
| d008fa4f78 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,7 +34,17 @@ export default class App {
|
||||
protected extendApp(): void { }
|
||||
protected extendMiddlewares(): void { }
|
||||
|
||||
private resolveTrustProxy(): boolean | number | string {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
if (!raw || raw.toLowerCase() === 'false') return false;
|
||||
if (raw.toLowerCase() === 'true') return true;
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
private initializeMiddlewares() {
|
||||
this.app.set('trust proxy', this.resolveTrustProxy());
|
||||
|
||||
//add tvJson method, clien need response format like {response: data}
|
||||
this.app.use((_req: Request, res: Response, next) => {
|
||||
res.tvJson = function (data: any) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'ta
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', de: 'Ohne Tags', es: 'Sin etiquetas' }
|
||||
|
||||
export type BuildTagAmountPayloadArgs = {
|
||||
rows: AmountPerTagMonthSectionRow[]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 { RequireAnyLoginMethod, 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,20 @@ 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);
|
||||
// Shared one-time-code redemption: magic-link emails, SSO callbacks and social
|
||||
// OAuth callbacks all complete the login through this endpoint
|
||||
this.router.post('/login-by-code', [RequireAnyLoginMethod(['magic-link', 'sso', 'social'])], 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 +42,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 +53,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 +62,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,29 @@
|
||||
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 RequireAnyLoginMethod = (methods: LoginMethod[]) => {
|
||||
return (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (!methods.some((method) => 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)
|
||||
|
||||
@@ -110,7 +110,8 @@ export class OidcProvider implements SsoProvider {
|
||||
throw new Error('CSRF state mismatch — possible CSRF attack')
|
||||
}
|
||||
|
||||
const currentUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`)
|
||||
const callbackOrigin = new URL(this.config.oidcCallbackUrl!).origin
|
||||
const currentUrl = new URL(req.originalUrl, callbackOrigin)
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: returnedState,
|
||||
|
||||
@@ -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.
|
||||
@@ -25,6 +25,15 @@ These must match your PostgreSQL setup.
|
||||
|---|---|---|---|
|
||||
| `APP_PORT` | No | `1401` | Port the API server listens on |
|
||||
| `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
|
||||
|
||||
@@ -34,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'))"`
|
||||
@@ -208,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).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.2",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
android/build/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
.swiftpm/
|
||||
ios/.build/
|
||||
DerivedData/
|
||||
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "CapacitorWidgetBridge",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapacitorWidgetBridge",
|
||||
targets: ["WidgetBridgePlugin"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "WidgetBridgePlugin",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
],
|
||||
path: "ios/Sources/WidgetBridgePlugin")
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
ext {
|
||||
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
||||
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
namespace = "tech.taskview.plugins.widgetbridge"
|
||||
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
||||
defaultConfig {
|
||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
|
||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError = false
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation project(':capacitor-android')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package tech.taskview.plugins.widgetbridge;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "WidgetBridge")
|
||||
public class WidgetBridgePlugin extends Plugin {
|
||||
|
||||
public static final String PREFS_NAME = "taskview_widget";
|
||||
public static final String SNAPSHOT_KEY = "widgetSnapshot";
|
||||
public static final String ACTION_WIDGET_UPDATE = "tech.taskview.widget.UPDATE";
|
||||
|
||||
@PluginMethod
|
||||
public void setSnapshot(PluginCall call) {
|
||||
String snapshot = call.getString("snapshot");
|
||||
if (snapshot == null) {
|
||||
call.reject("snapshot is required");
|
||||
return;
|
||||
}
|
||||
prefs().edit().putString(SNAPSHOT_KEY, snapshot).apply();
|
||||
notifyWidgets();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void clearSnapshot(PluginCall call) {
|
||||
prefs().edit().remove(SNAPSHOT_KEY).apply();
|
||||
notifyWidgets();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
private SharedPreferences prefs() {
|
||||
return getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private void notifyWidgets() {
|
||||
Context context = getContext();
|
||||
Intent intent = new Intent(ACTION_WIDGET_UPDATE);
|
||||
intent.setPackage(context.getPackageName());
|
||||
context.sendBroadcast(intent);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WidgetKit
|
||||
|
||||
@objc(WidgetBridgePlugin)
|
||||
public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "WidgetBridgePlugin"
|
||||
public let jsName = "WidgetBridge"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "setSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clearSnapshot", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
static let snapshotKey = "widgetSnapshot"
|
||||
|
||||
private var sharedDefaults: UserDefaults? {
|
||||
guard let appGroup = getConfig().getString("appGroup") else { return nil }
|
||||
return UserDefaults(suiteName: appGroup)
|
||||
}
|
||||
|
||||
@objc func setSnapshot(_ call: CAPPluginCall) {
|
||||
guard let snapshot = call.getString("snapshot") else {
|
||||
call.reject("snapshot is required")
|
||||
return
|
||||
}
|
||||
guard let defaults = sharedDefaults else {
|
||||
call.reject("WidgetBridge appGroup is not configured in capacitor.config")
|
||||
return
|
||||
}
|
||||
defaults.set(snapshot, forKey: Self.snapshotKey)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func clearSnapshot(_ call: CAPPluginCall) {
|
||||
guard let defaults = sharedDefaults else {
|
||||
call.reject("WidgetBridge appGroup is not configured in capacitor.config")
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.snapshotKey)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "capacitor-widget-bridge",
|
||||
"private": false,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Capacitor bridge that shares a data snapshot with native home-screen widgets (iOS WidgetKit / Android App Widgets)",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"ios",
|
||||
"android",
|
||||
"Package.swift"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
"src": "ios"
|
||||
},
|
||||
"android": {
|
||||
"src": "android"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type WidgetSnapshotTask = {
|
||||
id: number
|
||||
title: string
|
||||
priority: 1 | 2 | 3
|
||||
overdue: boolean
|
||||
endTime: string | null
|
||||
endDate: string | null
|
||||
path: string
|
||||
}
|
||||
|
||||
export type WidgetSnapshotMode = 'today' | 'upcoming'
|
||||
|
||||
export type WidgetSnapshot = {
|
||||
v: 3
|
||||
generatedAt: string
|
||||
locale: string
|
||||
orgSlug: string | null
|
||||
mode: WidgetSnapshotMode
|
||||
todayCount: number
|
||||
overdueCount: number
|
||||
upcomingCount: number
|
||||
tasks: WidgetSnapshotTask[]
|
||||
}
|
||||
|
||||
export type SetSnapshotOptions = {
|
||||
snapshot: string
|
||||
}
|
||||
|
||||
export type WidgetBridgePlugin = {
|
||||
setSnapshot(options: SetSnapshotOptions): Promise<void>
|
||||
clearSnapshot(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { registerPlugin } from '@capacitor/core'
|
||||
import type { WidgetBridgePlugin } from './definitions'
|
||||
|
||||
export const WidgetBridge = registerPlugin<WidgetBridgePlugin>('WidgetBridge')
|
||||
|
||||
export type {
|
||||
WidgetBridgePlugin,
|
||||
WidgetSnapshot,
|
||||
WidgetSnapshotMode,
|
||||
WidgetSnapshotTask,
|
||||
SetSnapshotOptions,
|
||||
} from './definitions'
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2020"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"noEmit": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export type AnalyticsRange = {
|
||||
export type LocalizedText = {
|
||||
ru: string
|
||||
en: string
|
||||
de?: string
|
||||
es?: string
|
||||
}
|
||||
|
||||
export type AnalyticsUnit =
|
||||
|
||||
@@ -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__'
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.handscreamgnl.taskview.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 14801
|
||||
versionName "1.48.1"
|
||||
versionCode 14902
|
||||
versionName "1.49.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies {
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-splash-screen')
|
||||
implementation project(':capgo-capacitor-updater')
|
||||
implementation project(':capacitor-widget-bridge')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,24 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.TodayWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_today_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
<action android:name="tech.taskview.widget.UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_today_info" />
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".widget.TodayWidgetService"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.handscream.taskview.app.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.view.View;
|
||||
import android.widget.RemoteViews;
|
||||
import android.widget.RemoteViewsService;
|
||||
|
||||
import com.handscream.taskview.app.R;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import tech.taskview.plugins.widgetbridge.WidgetBridgePlugin;
|
||||
|
||||
public class TodayWidgetFactory implements RemoteViewsService.RemoteViewsFactory {
|
||||
|
||||
private final Context context;
|
||||
private final List<JSONObject> tasks = new ArrayList<>();
|
||||
private boolean upcoming = false;
|
||||
|
||||
public TodayWidgetFactory(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataSetChanged() {
|
||||
tasks.clear();
|
||||
upcoming = false;
|
||||
String snapshot = context
|
||||
.getSharedPreferences(WidgetBridgePlugin.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(WidgetBridgePlugin.SNAPSHOT_KEY, null);
|
||||
if (snapshot == null) return;
|
||||
try {
|
||||
JSONObject parsed = new JSONObject(snapshot);
|
||||
upcoming = "upcoming".equals(parsed.optString("mode"));
|
||||
JSONArray items = parsed.optJSONArray("tasks");
|
||||
if (items == null) return;
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject task = items.optJSONObject(i);
|
||||
if (task != null) tasks.add(task);
|
||||
}
|
||||
} catch (JSONException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
tasks.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return tasks.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteViews getViewAt(int position) {
|
||||
JSONObject task = tasks.get(position);
|
||||
RemoteViews row = new RemoteViews(context.getPackageName(), R.layout.widget_today_item);
|
||||
|
||||
row.setTextViewText(R.id.widget_item_title, task.optString("title"));
|
||||
row.setImageViewResource(R.id.widget_item_checkbox, priorityCheckbox(task.optInt("priority", 1)));
|
||||
|
||||
boolean overdue = !upcoming && task.optBoolean("overdue", false);
|
||||
String meta = upcoming
|
||||
? formatEndDate(task)
|
||||
: (overdue ? context.getString(R.string.widget_overdue) : formatEndTime(task));
|
||||
if (meta == null || meta.isEmpty()) {
|
||||
row.setViewVisibility(R.id.widget_item_meta, View.GONE);
|
||||
} else {
|
||||
row.setViewVisibility(R.id.widget_item_meta, View.VISIBLE);
|
||||
row.setTextViewText(R.id.widget_item_meta, meta);
|
||||
row.setTextColor(
|
||||
R.id.widget_item_meta,
|
||||
context.getColor(overdue ? R.color.widget_overdue : R.color.widget_text_secondary));
|
||||
}
|
||||
|
||||
Intent fillIn = new Intent();
|
||||
String path = task.optString("path", "");
|
||||
if (!path.isEmpty()) {
|
||||
fillIn.setData(Uri.parse("taskview://open?path=" + Uri.encode(path)));
|
||||
}
|
||||
row.setOnClickFillInIntent(R.id.widget_item_root, fillIn);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private int priorityCheckbox(int priority) {
|
||||
switch (priority) {
|
||||
case 3:
|
||||
return R.drawable.widget_checkbox_high;
|
||||
case 2:
|
||||
return R.drawable.widget_checkbox_medium;
|
||||
default:
|
||||
return R.drawable.widget_checkbox_low;
|
||||
}
|
||||
}
|
||||
|
||||
private String formatEndTime(JSONObject task) {
|
||||
if (task.isNull("endTime")) return null;
|
||||
String endTime = task.optString("endTime", "");
|
||||
return endTime.length() >= 5 ? endTime.substring(0, 5) : endTime;
|
||||
}
|
||||
|
||||
private String formatEndDate(JSONObject task) {
|
||||
if (task.isNull("endDate")) return null;
|
||||
String endDate = task.optString("endDate", "");
|
||||
String[] parts = endDate.split("-");
|
||||
return parts.length == 3 ? parts[2] + "." + parts[1] : endDate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteViews getLoadingView() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getViewTypeCount() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return tasks.get(position).optLong("id", position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasStableIds() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.handscream.taskview.app.widget;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.handscream.taskview.app.MainActivity;
|
||||
import com.handscream.taskview.app.R;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import tech.taskview.plugins.widgetbridge.WidgetBridgePlugin;
|
||||
|
||||
public class TodayWidgetProvider extends AppWidgetProvider {
|
||||
|
||||
@Override
|
||||
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
|
||||
for (int appWidgetId : appWidgetIds) {
|
||||
manager.updateAppWidget(appWidgetId, buildViews(context, appWidgetId));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
super.onReceive(context, intent);
|
||||
if (WidgetBridgePlugin.ACTION_WIDGET_UPDATE.equals(intent.getAction())) {
|
||||
AppWidgetManager manager = AppWidgetManager.getInstance(context);
|
||||
int[] ids = manager.getAppWidgetIds(new ComponentName(context, TodayWidgetProvider.class));
|
||||
if (ids.length == 0) return;
|
||||
manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_today_list);
|
||||
onUpdate(context, manager, ids);
|
||||
}
|
||||
}
|
||||
|
||||
private RemoteViews buildViews(Context context, int appWidgetId) {
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_today);
|
||||
|
||||
JSONObject snapshot = readSnapshot(context);
|
||||
boolean upcoming = snapshot != null && "upcoming".equals(snapshot.optString("mode"));
|
||||
int count = snapshot == null
|
||||
? 0
|
||||
: (upcoming ? snapshot.optInt("upcomingCount", 0) : snapshot.optInt("todayCount", 0));
|
||||
views.setTextViewText(
|
||||
R.id.widget_today_title,
|
||||
context.getString(upcoming ? R.string.widget_upcoming_title : R.string.widget_today_title));
|
||||
views.setTextViewText(R.id.widget_today_count, String.valueOf(count));
|
||||
|
||||
Intent adapter = new Intent(context, TodayWidgetService.class);
|
||||
adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||
adapter.setData(Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)));
|
||||
views.setRemoteAdapter(R.id.widget_today_list, adapter);
|
||||
views.setEmptyView(R.id.widget_today_list, R.id.widget_today_empty);
|
||||
|
||||
PendingIntent openApp = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
new Intent(context, MainActivity.class),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
views.setOnClickPendingIntent(R.id.widget_today_header, openApp);
|
||||
views.setOnClickPendingIntent(R.id.widget_today_empty, openApp);
|
||||
|
||||
Intent template = new Intent(context, MainActivity.class);
|
||||
template.setAction(Intent.ACTION_VIEW);
|
||||
PendingIntent templateIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
1,
|
||||
template,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
|
||||
views.setPendingIntentTemplate(R.id.widget_today_list, templateIntent);
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
private JSONObject readSnapshot(Context context) {
|
||||
String snapshot = context
|
||||
.getSharedPreferences(WidgetBridgePlugin.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(WidgetBridgePlugin.SNAPSHOT_KEY, null);
|
||||
if (snapshot == null) return null;
|
||||
try {
|
||||
return new JSONObject(snapshot);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.handscream.taskview.app.widget;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.widget.RemoteViewsService;
|
||||
|
||||
public class TodayWidgetService extends RemoteViewsService {
|
||||
|
||||
@Override
|
||||
public RemoteViewsFactory onGetViewFactory(Intent intent) {
|
||||
return new TodayWidgetFactory(getApplicationContext());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_accent" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_background" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke
|
||||
android:width="1.5dp"
|
||||
android:color="@color/widget_priority_high" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke
|
||||
android:width="1.5dp"
|
||||
android:color="@color/widget_priority_low" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke
|
||||
android:width="1.5dp"
|
||||
android:color="@color/widget_priority_medium" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_header_background" />
|
||||
<corners
|
||||
android:topLeftRadius="16dp"
|
||||
android:topRightRadius="16dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/widget_bg">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_today_header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/widget_header_bg"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widget_today_logo"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:src="@mipmap/ic_launcher_round"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_today_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/widget_today_title"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_today_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/widget_badge_bg"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="3dp"
|
||||
android:paddingBottom="3dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ListView
|
||||
android:id="@+id/widget_today_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:divider="@color/widget_divider"
|
||||
android:dividerHeight="0.5dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_today_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:text="@string/widget_today_empty"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="13sp" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/widget_item_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widget_item_checkbox"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/widget_checkbox_low"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_item_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="2dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_item_meta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_background">#18181B</color>
|
||||
<color name="widget_header_background">#27272A</color>
|
||||
<color name="widget_divider">#3F3F46</color>
|
||||
<color name="widget_text_primary">#FAFAFA</color>
|
||||
<color name="widget_text_secondary">#A1A1AA</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="widget_today_title">Сегодня</string>
|
||||
<string name="widget_upcoming_title">Ближайшие</string>
|
||||
<string name="widget_today_empty">Нет задач на сегодня</string>
|
||||
<string name="widget_overdue">Просрочено</string>
|
||||
<string name="widget_today_description">Ваши задачи на сегодня</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="widget_today_title">Today</string>
|
||||
<string name="widget_upcoming_title">Upcoming</string>
|
||||
<string name="widget_today_empty">No tasks for today</string>
|
||||
<string name="widget_overdue">Overdue</string>
|
||||
<string name="widget_today_description">Your tasks for today at a glance</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_background">#FFFFFF</color>
|
||||
<color name="widget_header_background">#F4F4F5</color>
|
||||
<color name="widget_divider">#E4E4E7</color>
|
||||
<color name="widget_text_primary">#18181B</color>
|
||||
<color name="widget_text_secondary">#71717A</color>
|
||||
<color name="widget_accent">#16A34A</color>
|
||||
<color name="widget_overdue">#FF1744</color>
|
||||
<color name="widget_priority_low">#38D681</color>
|
||||
<color name="widget_priority_medium">#FF9100</color>
|
||||
<color name="widget_priority_high">#FF1744</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="70dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:initialLayout="@layout/widget_today"
|
||||
android:previewLayout="@layout/widget_today"
|
||||
android:description="@string/widget_today_description" />
|
||||
@@ -1,27 +1,30 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
|
||||
project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-firebase-messaging'
|
||||
project(':capacitor-firebase-messaging').projectDir = new File('../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging/android')
|
||||
project(':capacitor-firebase-messaging').projectDir = new File('../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.11.0/node_modules/@capacitor-firebase/messaging/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
|
||||
project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-browser'
|
||||
project(':capacitor-browser').projectDir = new File('../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
|
||||
project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
|
||||
|
||||
include ':capacitor-device'
|
||||
project(':capacitor-device').projectDir = new File('../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
|
||||
project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
|
||||
project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications/android')
|
||||
project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.3_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-splash-screen'
|
||||
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
|
||||
project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
|
||||
|
||||
include ':capgo-capacitor-updater'
|
||||
project(':capgo-capacitor-updater').projectDir = new File('../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
|
||||
project(':capgo-capacitor-updater').projectDir = new File('../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
|
||||
|
||||
include ':capacitor-widget-bridge'
|
||||
project(':capacitor-widget-bridge').projectDir = new File('../node_modules/capacitor-widget-bridge/android')
|
||||
|
||||
@@ -25,6 +25,9 @@ const config: CapacitorConfig = {
|
||||
CapacitorHttp: {
|
||||
enabled: true,
|
||||
},
|
||||
WidgetBridge: {
|
||||
appGroup: 'group.com.handscream.taskview.app',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
30163AB74EEE183517D215DC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41EECE9262690D5E2EF23CE9 /* Assets.xcassets */; };
|
||||
30F5ABA9CC301D3E3BA985C7 /* TaskViewWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */; };
|
||||
336711805E8F2928D054F35E /* TodayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
@@ -15,11 +18,43 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
7E638BA1BFCF4E185865F85D /* TaskViewWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
A71181DC51C8DA03879B72ED /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */; };
|
||||
AAA632B22F6BB598007C705D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAA632B12F6BB598007C705D /* GoogleService-Info.plist */; };
|
||||
ABED08BA434D23E174534E03 /* WidgetSnapshotModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */; };
|
||||
AB10F00D00000000000000A2 /* OpenTaskIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10F00D00000000000000A1 /* OpenTaskIntent.swift */; };
|
||||
AB10F00D00000000000000A3 /* OpenTaskIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10F00D00000000000000A1 /* OpenTaskIntent.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
8471300B26377F4336A38D36 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 13D58B519FF779A139CB09EB;
|
||||
remoteInfo = TaskViewWidget;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
E6DDB52E9A683D16F43B337C /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
7E638BA1BFCF4E185865F85D /* TaskViewWidget.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
41EECE9262690D5E2EF23CE9 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayWidget.swift; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
@@ -28,9 +63,15 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TaskViewWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotModel.swift; sourceTree = "<group>"; };
|
||||
AB10F00D00000000000000A1 /* OpenTaskIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OpenTaskIntent.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskViewWidgetBundle.swift; sourceTree = "<group>"; };
|
||||
AAA632B02F69F429007C705D /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
AAA632B12F6BB598007C705D /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
EB7495A9A40AB2365CC0624E /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
F1EEEB83D95DC32C9728EAC4 /* TaskViewWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TaskViewWidget.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -42,9 +83,25 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
8331741D08C05C5C85275E50 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A71181DC51C8DA03879B72ED /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
4961C8EBB434EDD15B55395B /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7E3EA782F4406B2B6D7A0121 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -52,6 +109,8 @@
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
4961C8EBB434EDD15B55395B /* Frameworks */,
|
||||
79D12E01B1FBD77844324865 /* TaskViewWidget */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -59,6 +118,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -79,9 +139,49 @@
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
79D12E01B1FBD77844324865 /* TaskViewWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */,
|
||||
4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */,
|
||||
873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */,
|
||||
AB10F00D00000000000000A1 /* OpenTaskIntent.swift */,
|
||||
EB7495A9A40AB2365CC0624E /* Info.plist */,
|
||||
F1EEEB83D95DC32C9728EAC4 /* TaskViewWidget.entitlements */,
|
||||
41EECE9262690D5E2EF23CE9 /* Assets.xcassets */,
|
||||
);
|
||||
name = TaskViewWidget;
|
||||
path = TaskViewWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7E3EA782F4406B2B6D7A0121 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
13D58B519FF779A139CB09EB /* TaskViewWidget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BA6B4CB7C019DA2E47F2AE5D /* Build configuration list for PBXNativeTarget "TaskViewWidget" */;
|
||||
buildPhases = (
|
||||
2B48B6699C53A2E975CEE972 /* Sources */,
|
||||
8331741D08C05C5C85275E50 /* Frameworks */,
|
||||
8F380AD759A2116160808CCC /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = TaskViewWidget;
|
||||
productName = TaskViewWidget;
|
||||
productReference = 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
@@ -89,10 +189,12 @@
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
E6DDB52E9A683D16F43B337C /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
70738FEC670F89727813A33B /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
@@ -135,6 +237,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
13D58B519FF779A139CB09EB /* TaskViewWidget */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -154,19 +257,48 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
8F380AD759A2116160808CCC /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
30163AB74EEE183517D215DC /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
2B48B6699C53A2E975CEE972 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
30F5ABA9CC301D3E3BA985C7 /* TaskViewWidgetBundle.swift in Sources */,
|
||||
336711805E8F2928D054F35E /* TodayWidget.swift in Sources */,
|
||||
ABED08BA434D23E174534E03 /* WidgetSnapshotModel.swift in Sources */,
|
||||
AB10F00D00000000000000A2 /* OpenTaskIntent.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
AB10F00D00000000000000A3 /* OpenTaskIntent.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
70738FEC670F89727813A33B /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TaskViewWidget;
|
||||
target = 13D58B519FF779A139CB09EB /* TaskViewWidget */;
|
||||
targetProxy = 8471300B26377F4336A38D36 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
@@ -304,7 +436,7 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1.48.1;
|
||||
CURRENT_PROJECT_VERSION = 1.49.2;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
@@ -312,7 +444,12 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.48.1;
|
||||
MARKETING_VERSION = 1.49.2;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-weak_framework",
|
||||
AppIntents,
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -329,7 +466,7 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1.48.1;
|
||||
CURRENT_PROJECT_VERSION = 1.49.2;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
@@ -337,7 +474,12 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.48.1;
|
||||
MARKETING_VERSION = 1.49.2;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-weak_framework",
|
||||
AppIntents,
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -346,6 +488,65 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
578B436A7C18D66B2BDFCF0B /* Debug */ = {
|
||||
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;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TaskViewWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "TaskView Widget";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.49.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AC8696991AE5C61127E97D40 /* Release */ = {
|
||||
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;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TaskViewWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "TaskView Widget";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.49.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -367,6 +568,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
BA6B4CB7C019DA2E47F2AE5D /* Build configuration list for PBXNativeTarget "TaskViewWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AC8696991AE5C61127E97D40 /* Release */,
|
||||
578B436A7C18D66B2BDFCF0B /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -4,5 +4,9 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.handscream.taskview.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -12,14 +12,15 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.1.0"),
|
||||
.package(name: "CapacitorFirebaseMessaging", path: "../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging"),
|
||||
.package(name: "CapacitorApp", path: "../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorBrowser", path: "../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"),
|
||||
.package(name: "CapacitorDevice", path: "../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"),
|
||||
.package(name: "CapacitorPreferences", path: "../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"),
|
||||
.package(name: "CapacitorPushNotifications", path: "../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"),
|
||||
.package(name: "CapacitorSplashScreen", path: "../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"),
|
||||
.package(name: "CapgoCapacitorUpdater", path: "../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater")
|
||||
.package(name: "CapacitorFirebaseMessaging", path: "../../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.11.0/node_modules/@capacitor-firebase/messaging"),
|
||||
.package(name: "CapacitorApp", path: "../../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorBrowser", path: "../../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"),
|
||||
.package(name: "CapacitorDevice", path: "../../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"),
|
||||
.package(name: "CapacitorPreferences", path: "../../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"),
|
||||
.package(name: "CapacitorPushNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.3_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"),
|
||||
.package(name: "CapacitorSplashScreen", path: "../../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"),
|
||||
.package(name: "CapgoCapacitorUpdater", path: "../../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater"),
|
||||
.package(name: "CapacitorWidgetBridge", path: "../../../node_modules/capacitor-widget-bridge")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -34,7 +35,8 @@ let package = Package(
|
||||
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
|
||||
.product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"),
|
||||
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
|
||||
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater")
|
||||
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater"),
|
||||
.product(name: "CapacitorWidgetBridge", package: "CapacitorWidgetBridge")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import AppIntents
|
||||
#if !WIDGET_EXTENSION
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct OpenTaskIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Open Task"
|
||||
static let isDiscoverable = false
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "URL")
|
||||
var urlString: String
|
||||
|
||||
init() {
|
||||
urlString = "taskview://open"
|
||||
}
|
||||
|
||||
init(urlString: String) {
|
||||
self.urlString = urlString
|
||||
}
|
||||
|
||||
private var resolvedURL: URL {
|
||||
URL(string: urlString) ?? URL(string: "taskview://open")!
|
||||
}
|
||||
|
||||
#if WIDGET_EXTENSION
|
||||
func perform() async throws -> some IntentResult & OpensIntent {
|
||||
.result(opensIntent: OpenURLIntent(resolvedURL))
|
||||
}
|
||||
#else
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
await UIApplication.shared.open(resolvedURL)
|
||||
return .result()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.handscream.taskview.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,9 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TaskViewWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
TodayWidget()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct TodayEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WidgetSnapshot?
|
||||
}
|
||||
|
||||
struct TodayProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TodayEntry {
|
||||
TodayEntry(date: .now, snapshot: nil)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TodayEntry) -> Void) {
|
||||
completion(TodayEntry(date: .now, snapshot: WidgetSnapshot.load()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayEntry>) -> Void) {
|
||||
let entry = TodayEntry(date: .now, snapshot: WidgetSnapshot.load())
|
||||
let refresh = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
|
||||
completion(Timeline(entries: [entry], policy: .after(refresh)))
|
||||
}
|
||||
}
|
||||
|
||||
struct TodayWidget: Widget {
|
||||
let kind = "TaskViewTodayWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: TodayProvider()) { entry in
|
||||
TodayWidgetView(entry: entry)
|
||||
.containerBackground(for: .widget) {
|
||||
Color(uiColor: .systemBackground)
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("TaskView")
|
||||
.description("Today's tasks / Задачи на сегодня")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
struct TodayWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
let entry: TodayEntry
|
||||
|
||||
private var strings: WidgetStrings {
|
||||
WidgetStrings.forLocale(entry.snapshot?.locale)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
TaskListTodayView(
|
||||
snapshot: entry.snapshot,
|
||||
strings: strings,
|
||||
maxSlots: slots(for: geo.size.height),
|
||||
compact: family == .systemSmall,
|
||||
pinMoreToBottom: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)))
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskListTodayView: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let strings: WidgetStrings
|
||||
let maxSlots: Int
|
||||
let compact: Bool
|
||||
let pinMoreToBottom: Bool
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private var visibleTasks: [WidgetSnapshotTask] {
|
||||
guard let snapshot else { return [] }
|
||||
let count = snapshot.activeCount > maxSlots ? maxSlots - 1 : maxSlots
|
||||
return Array(snapshot.tasks.prefix(count))
|
||||
}
|
||||
|
||||
private var hiddenCount: Int {
|
||||
guard let snapshot else { return 0 }
|
||||
return max(0, snapshot.activeCount - visibleTasks.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: compact ? 6 : 8) {
|
||||
WidgetLogoView(size: compact ? 16 : 20)
|
||||
|
||||
Text(isUpcoming ? strings.upcoming : strings.today)
|
||||
.font(compact ? .footnote.weight(.semibold) : .headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(snapshot?.activeCount ?? 0)")
|
||||
.font(.caption2.weight(.bold))
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, compact ? 6 : 8)
|
||||
.padding(.vertical, compact ? 2 : 3)
|
||||
.background(WidgetPalette.accent, in: Capsule())
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.top, compact ? 16 : 18)
|
||||
.padding(.bottom, compact ? 4 : 6)
|
||||
.background(WidgetPalette.headerBackground)
|
||||
|
||||
if !visibleTasks.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(visibleTasks.enumerated()), id: \.element.id) { index, task in
|
||||
VStack(spacing: 0) {
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.padding(.leading, dividerInset)
|
||||
}
|
||||
|
||||
TaskRowView(task: task, strings: strings, compact: compact, showDate: isUpcoming)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.frame(height: pinMoreToBottom ? rowFixedHeight : nil)
|
||||
.padding(.vertical, pinMoreToBottom ? 0 : rowVerticalPadding)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .trailing)))
|
||||
}
|
||||
|
||||
if hiddenCount > 0 {
|
||||
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)
|
||||
.frame(height: pinMoreToBottom ? rowFixedHeight : nil)
|
||||
.padding(.vertical, pinMoreToBottom ? 0 : rowVerticalPadding)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.font(compact ? .caption : .footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.widgetURL(compact ? URL(string: "taskview://open") : nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskRowView: View {
|
||||
let task: WidgetSnapshotTask
|
||||
let strings: WidgetStrings
|
||||
let compact: Bool
|
||||
let showDate: Bool
|
||||
|
||||
private var destination: URL {
|
||||
task.deepLinkURL ?? URL(string: "taskview://open")!
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if compact {
|
||||
if #available(iOS 18.0, *) {
|
||||
Button(intent: OpenTaskIntent(urlString: destination.absoluteString)) {
|
||||
row
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
} else {
|
||||
Link(destination: destination) {
|
||||
row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var row: some View {
|
||||
HStack(spacing: compact ? 4 : 6) {
|
||||
Circle()
|
||||
.strokeBorder(WidgetPalette.priority(task.priority), lineWidth: compact ? 1.2 : 1.5)
|
||||
.frame(width: compact ? 14 : 18, height: compact ? 14 : 18)
|
||||
.padding(4)
|
||||
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
HStack(spacing: compact ? 8 : 10) {
|
||||
Text(task.title)
|
||||
.font(compact ? .caption : .subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if showDate {
|
||||
if let endDate = task.shortEndDate {
|
||||
Text(endDate)
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if task.overdue {
|
||||
Text(compact ? "!" : strings.overdue)
|
||||
.font(compact ? .caption.weight(.bold) : .caption.weight(.medium))
|
||||
.foregroundStyle(WidgetPalette.overdue)
|
||||
} else if let endTime = task.shortEndTime {
|
||||
Text(endTime)
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetLogoView: View {
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Image("WidgetLogo")
|
||||
.resizable()
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: size * 0.28))
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetPalette {
|
||||
static let accent = Color(red: 0.086, green: 0.639, blue: 0.290)
|
||||
static let overdue = Color(red: 1.0, green: 0.090, blue: 0.267)
|
||||
static let headerBackground = Color(uiColor: .secondarySystemBackground).opacity(0.7)
|
||||
|
||||
static func priority(_ priority: Int) -> Color {
|
||||
switch priority {
|
||||
case 3: return Color(red: 1.0, green: 0.090, blue: 0.267)
|
||||
case 2: return Color(red: 1.0, green: 0.569, blue: 0.0)
|
||||
default: return Color(red: 0.220, green: 0.839, blue: 0.506)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
struct WidgetSnapshotTask: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let priority: Int
|
||||
let overdue: Bool
|
||||
let endTime: String?
|
||||
let endDate: String?
|
||||
let path: String
|
||||
|
||||
var deepLinkURL: URL? {
|
||||
guard let encoded = path.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { return nil }
|
||||
return URL(string: "taskview://open?path=\(encoded)")
|
||||
}
|
||||
|
||||
var shortEndTime: String? {
|
||||
guard let endTime, endTime.count >= 5 else { return endTime }
|
||||
return String(endTime.prefix(5))
|
||||
}
|
||||
|
||||
var shortEndDate: String? {
|
||||
guard let endDate else { return nil }
|
||||
let parts = endDate.split(separator: "-")
|
||||
guard parts.count == 3 else { return endDate }
|
||||
return "\(parts[2]).\(parts[1])"
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetSnapshot: Decodable {
|
||||
let v: Int
|
||||
let generatedAt: String
|
||||
let locale: String
|
||||
let orgSlug: String?
|
||||
let mode: String?
|
||||
let todayCount: Int
|
||||
let overdueCount: Int
|
||||
let upcomingCount: Int?
|
||||
let tasks: [WidgetSnapshotTask]
|
||||
|
||||
var isUpcoming: Bool {
|
||||
mode == "upcoming"
|
||||
}
|
||||
|
||||
var activeCount: Int {
|
||||
isUpcoming ? (upcomingCount ?? tasks.count) : todayCount
|
||||
}
|
||||
|
||||
static let appGroup = "group.com.handscream.taskview.app"
|
||||
static let snapshotKey = "widgetSnapshot"
|
||||
|
||||
static func load() -> WidgetSnapshot? {
|
||||
guard let defaults = UserDefaults(suiteName: appGroup),
|
||||
let raw = defaults.string(forKey: snapshotKey),
|
||||
let data = raw.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetStrings {
|
||||
let today: String
|
||||
let upcoming: String
|
||||
let empty: String
|
||||
let overdue: String
|
||||
let openApp: String
|
||||
let moreFormat: String
|
||||
|
||||
static let ru = WidgetStrings(
|
||||
today: "Сегодня",
|
||||
upcoming: "Ближайшие",
|
||||
empty: "Нет задач на сегодня",
|
||||
overdue: "Просрочено",
|
||||
openApp: "Откройте TaskView",
|
||||
moreFormat: "и ещё %d"
|
||||
)
|
||||
|
||||
static let en = WidgetStrings(
|
||||
today: "Today",
|
||||
upcoming: "Upcoming",
|
||||
empty: "No tasks for today",
|
||||
overdue: "Overdue",
|
||||
openApp: "Open TaskView",
|
||||
moreFormat: "+%d more"
|
||||
)
|
||||
|
||||
func more(_ count: Int) -> String {
|
||||
String(format: moreFormat, count)
|
||||
}
|
||||
|
||||
static func forLocale(_ locale: String?) -> WidgetStrings {
|
||||
let resolved = locale ?? Locale.preferredLanguages.first ?? "ru"
|
||||
return resolved.hasPrefix("ru") ? ru : en
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -2,10 +2,10 @@
|
||||
"name": "web-nuxt-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
|
||||
"build": "pnpm run build:packages && pnpm run typecheck && vite build",
|
||||
"build-only": "vite build",
|
||||
"preview": "vite preview",
|
||||
@@ -58,6 +58,7 @@
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"arktype": "2.1.20",
|
||||
"axios": "1.13.5",
|
||||
"capacitor-widget-bridge": "workspace:^",
|
||||
"centrifuge": "^5.5.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
|
||||
@@ -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__ = {}
|
||||
@@ -38,85 +38,33 @@
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
|
||||
<template #chip-leading="{ item }">
|
||||
<div class="inline-flex items-center justify-center shrink-0 size-5">
|
||||
<span
|
||||
class="rounded-full ring ring-bg bg-(--chip-light) dark:bg-(--chip-dark) size-2"
|
||||
:style="{
|
||||
'--chip-light': `var(--color-${(item as any).chip}-500)`,
|
||||
'--chip-dark': `var(--color-${(item as any).chip}-400)`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app.store'
|
||||
import { useLogout } from '@/composables/useLogout'
|
||||
import { saveLocale } from '@/plugins/i18n'
|
||||
import { useUpdater } from '@/composables/useUpdater'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
|
||||
import { $ls } from '@/plugins/axios'
|
||||
import avatarImg from '@/assets/images/avatar-1.jpeg'
|
||||
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
const { switchOrg } = useOrgSwitcher()
|
||||
|
||||
const prodOrDev = ref<'prod' | 'dev'>('prod')
|
||||
let counter = 0
|
||||
let timeout: number
|
||||
let timeoutChangeProdOrDev: number
|
||||
|
||||
onMounted(async () => {
|
||||
prodOrDev.value = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
counter++
|
||||
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (timeoutChangeProdOrDev) clearTimeout(timeoutChangeProdOrDev)
|
||||
|
||||
timeout = window.setTimeout(() => {
|
||||
counter = 0
|
||||
}, 500)
|
||||
|
||||
if (counter === 7) {
|
||||
timeoutChangeProdOrDev = window.setTimeout(async () => {
|
||||
if ((await $ls.getValue('update_loading')) !== 'dev') {
|
||||
await $ls.setValue('update_loading', 'dev')
|
||||
prodOrDev.value = 'dev'
|
||||
} else {
|
||||
await $ls.setValue('update_loading', 'prod')
|
||||
prodOrDev.value = 'prod'
|
||||
}
|
||||
console.log(await $ls.getValue('update_loading'))
|
||||
await useUpdater(true)
|
||||
clearTimeout(timeoutChangeProdOrDev)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
const success = await useLogout()
|
||||
if (success) {
|
||||
@@ -151,7 +99,6 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
{
|
||||
label: orgStore.currentOrg?.name || t('userMenu.switchOrganization'),
|
||||
icon: 'i-lucide-building-2',
|
||||
|
||||
children: orgStore.organizations.map(org => ({
|
||||
label: org.name,
|
||||
icon: org.id === orgStore.currentOrg?.id ? 'i-lucide-check' : undefined,
|
||||
@@ -164,42 +111,12 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
] : [],
|
||||
[
|
||||
{
|
||||
label: t('userMenu.accountSettings'),
|
||||
label: t('settings.title'),
|
||||
icon: 'i-lucide-settings',
|
||||
onSelect() {
|
||||
router.push({ name: 'account' })
|
||||
router.push({ name: 'settings' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.uiCustomization'),
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
onSelect() {
|
||||
router.push({ name: 'ui-customization' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.organizations'),
|
||||
icon: 'i-lucide-building-2',
|
||||
onSelect() {
|
||||
router.push({ name: 'organizations' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.analytics'),
|
||||
icon: 'i-lucide-bar-chart-3',
|
||||
onSelect() {
|
||||
router.push({ name: 'analytics' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.timeReports'),
|
||||
icon: 'i-lucide-clock-4',
|
||||
onSelect() {
|
||||
router.push({ name: 'time-reports' })
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: t('userMenu.appearance'),
|
||||
icon: 'i-lucide-sun-moon',
|
||||
@@ -219,147 +136,13 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
icon: 'i-lucide-moon',
|
||||
type: 'checkbox',
|
||||
checked: colorMode.value === 'dark',
|
||||
onUpdateChecked(checked: boolean) {
|
||||
if (checked) {
|
||||
colorMode.value = 'dark'
|
||||
}
|
||||
},
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
colorMode.value = 'dark'
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('userMenu.language'),
|
||||
icon: 'i-lucide-languages',
|
||||
children: [
|
||||
{
|
||||
label: 'English',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'en',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('en')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Русский',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'ru',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('ru')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Deutsch',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'de',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('de')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Español',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'es',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('es')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('userMenu.taskDetailView'),
|
||||
icon: 'i-lucide-panel-right',
|
||||
children: [
|
||||
{
|
||||
label: t('userMenu.taskDetailSlideover'),
|
||||
icon: 'i-lucide-panel-right',
|
||||
type: 'checkbox',
|
||||
checked: appStore.taskDetailDisplayMode === 'slideover',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setTaskDetailDisplayMode('slideover')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.taskDetailModal'),
|
||||
icon: 'i-lucide-square',
|
||||
type: 'checkbox',
|
||||
checked: appStore.taskDetailDisplayMode === 'modal',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setTaskDetailDisplayMode('modal')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('userMenu.sidebarView'),
|
||||
icon: 'i-lucide-panel-left',
|
||||
children: [
|
||||
{
|
||||
label: t('userMenu.sidebarViewSecond'),
|
||||
icon: 'i-lucide-layout-list',
|
||||
type: 'checkbox',
|
||||
checked: appStore.sidebarView === 'second',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setSidebarView('second')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.sidebarViewFirst'),
|
||||
icon: 'i-lucide-panel-left',
|
||||
type: 'checkbox',
|
||||
checked: appStore.sidebarView === 'first',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setSidebarView('first')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: t('userMenu.site'),
|
||||
icon: 'i-lucide-globe',
|
||||
to: 'https://taskview.tech/',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: t('userMenu.documentation'),
|
||||
icon: 'i-lucide-book-open',
|
||||
to: 'https://taskview.tech/docs/',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: t('userMenu.github'),
|
||||
icon: 'simple-icons:github',
|
||||
to: 'https://github.com/Gimanh/taskview-community',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: t('userMenu.docker'),
|
||||
icon: 'simple-icons:docker',
|
||||
to: 'https://hub.docker.com/u/gimanhead',
|
||||
target: '_blank',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: `v ${APP_VERSION}${prodOrDev.value === 'dev' ? '_d' : ''}`,
|
||||
icon: 'i-lucide-info',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
checkForUpdates()
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { isSidebarOpen } = useDashboard()
|
||||
const { isUserRoute, isAccountRoute, hasProject, projectId } = useAppRouteInfo()
|
||||
const { isUserRoute, isSettingsRoute, hasProject, projectId } = useAppRouteInfo()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const currentGoal = computed(() =>
|
||||
@@ -105,11 +105,11 @@ const navItems = computed<NavItem[]>(() => {
|
||||
// so drop Settings there — it stays reachable from the global tabs.
|
||||
if (!hasProject.value) {
|
||||
items.push({
|
||||
key: 'account',
|
||||
key: 'settings',
|
||||
label: t('account.nav'),
|
||||
icon: 'i-lucide-settings',
|
||||
to: { name: 'account' },
|
||||
active: () => isAccountRoute.value,
|
||||
to: { name: 'settings' },
|
||||
active: () => isSettingsRoute.value,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="mx-auto flex w-full max-w-2xl flex-col gap-6 p-3 pb-24 lg:p-6 lg:pb-6">
|
||||
<SettingsProfileHeader
|
||||
:name="identity.name"
|
||||
:email="identity.email"
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
:title="section.title"
|
||||
>
|
||||
<template
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
>
|
||||
<SettingsNavRow
|
||||
v-if="item.kind === 'nav'"
|
||||
:item="item"
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
v-else
|
||||
:item="item"
|
||||
/>
|
||||
</template>
|
||||
<SettingsVersionRow v-if="section.key === 'about'" />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsLogoutRow />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsHub } from './composables/useSettingsHub'
|
||||
import SettingsProfileHeader from './parts/SettingsProfileHeader.vue'
|
||||
import SettingsSection from './parts/SettingsSection.vue'
|
||||
import SettingsNavRow from './parts/SettingsNavRow.vue'
|
||||
import SettingsSelectRow from './parts/SettingsSelectRow.vue'
|
||||
import SettingsVersionRow from './parts/SettingsVersionRow.vue'
|
||||
import SettingsLogoutRow from './parts/SettingsLogoutRow.vue'
|
||||
|
||||
const { identity, sections } = useSettingsHub()
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import type { Locale } from '@/locales'
|
||||
import type { SidebarView, TaskDetailDisplayMode } from '@/types/global-app.types'
|
||||
import { useAppStore } from '@/stores/app.store'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
|
||||
import { saveLocale } from '@/plugins/i18n'
|
||||
import type { SettingsSection } from '../types'
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Русский', value: 'ru' },
|
||||
{ label: 'Deutsch', value: 'de' },
|
||||
{ label: 'Español', value: 'es' },
|
||||
]
|
||||
|
||||
export function useSettingsHub() {
|
||||
const { t, locale } = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
const { switchOrg } = useOrgSwitcher()
|
||||
|
||||
const identity = computed(() => ({
|
||||
name: userStore.login || userStore.email,
|
||||
email: userStore.email,
|
||||
}))
|
||||
|
||||
const sections = computed<SettingsSection[]>(() => {
|
||||
const account: SettingsSection = {
|
||||
key: 'account',
|
||||
title: t('settings.account'),
|
||||
items: [
|
||||
{ kind: 'nav', key: 'account', icon: 'i-lucide-user-cog', color: 'emerald', title: t('userMenu.accountSettings'), to: { name: 'account' } },
|
||||
{ kind: 'nav', key: 'organizations', icon: 'i-lucide-building-2', color: 'violet', title: t('userMenu.organizations'), to: { name: 'organizations' } },
|
||||
],
|
||||
}
|
||||
|
||||
if (orgStore.organizations.length > 1) {
|
||||
account.items.unshift({
|
||||
kind: 'select',
|
||||
key: 'org-switch',
|
||||
icon: 'i-lucide-arrow-left-right',
|
||||
color: 'indigo',
|
||||
title: t('userMenu.switchOrganization'),
|
||||
options: orgStore.organizations.map((o) => ({ label: o.name, value: String(o.id) })),
|
||||
get: () => String(orgStore.currentOrg?.id ?? ''),
|
||||
set: (value) => {
|
||||
const org = orgStore.organizations.find((o) => String(o.id) === value)
|
||||
if (org) switchOrg(org)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const appearance: SettingsSection = {
|
||||
key: 'appearance',
|
||||
title: t('settings.appearance'),
|
||||
items: [
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'theme',
|
||||
icon: 'i-lucide-sun-moon',
|
||||
color: 'amber',
|
||||
title: t('userMenu.appearance'),
|
||||
options: [
|
||||
{ label: t('userMenu.light'), value: 'light' },
|
||||
{ label: t('userMenu.dark'), value: 'dark' },
|
||||
{ label: t('settings.system'), value: 'auto' },
|
||||
],
|
||||
get: () => colorMode.value,
|
||||
set: (value) => { colorMode.value = value as typeof colorMode.value },
|
||||
},
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'language',
|
||||
icon: 'i-lucide-languages',
|
||||
color: 'sky',
|
||||
title: t('userMenu.language'),
|
||||
options: LANGUAGE_OPTIONS,
|
||||
get: () => locale.value,
|
||||
set: (value) => saveLocale(value as Locale),
|
||||
},
|
||||
{ kind: 'nav', key: 'ui-customization', icon: 'i-lucide-sliders-horizontal', color: 'rose', title: t('userMenu.uiCustomization'), to: { name: 'ui-customization' } },
|
||||
],
|
||||
}
|
||||
|
||||
const layout: SettingsSection = {
|
||||
key: 'layout',
|
||||
title: t('settings.layout'),
|
||||
items: [
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'task-detail',
|
||||
icon: 'i-lucide-panel-right',
|
||||
color: 'indigo',
|
||||
title: t('userMenu.taskDetailView'),
|
||||
options: [
|
||||
{ label: t('userMenu.taskDetailSlideover'), value: 'slideover' },
|
||||
{ label: t('userMenu.taskDetailModal'), value: 'modal' },
|
||||
],
|
||||
get: () => appStore.taskDetailDisplayMode,
|
||||
set: (value) => appStore.setTaskDetailDisplayMode(value as TaskDetailDisplayMode),
|
||||
},
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'sidebar-view',
|
||||
icon: 'i-lucide-panel-left',
|
||||
color: 'teal',
|
||||
title: t('userMenu.sidebarView'),
|
||||
options: [
|
||||
{ label: t('userMenu.sidebarViewSecond'), value: 'second' },
|
||||
{ label: t('userMenu.sidebarViewFirst'), value: 'first' },
|
||||
],
|
||||
get: () => appStore.sidebarView,
|
||||
set: (value) => appStore.setSidebarView(value as SidebarView),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const about: SettingsSection = {
|
||||
key: 'about',
|
||||
title: t('settings.about'),
|
||||
items: [
|
||||
{ kind: 'nav', key: 'site', icon: 'i-lucide-globe', color: 'zinc', title: t('userMenu.site'), href: 'https://taskview.tech/' },
|
||||
{ kind: 'nav', key: 'docs', icon: 'i-lucide-book-open', color: 'zinc', title: t('userMenu.documentation'), href: 'https://taskview.tech/docs/' },
|
||||
{ kind: 'nav', key: 'github', icon: 'simple-icons:github', color: 'zinc', title: t('userMenu.github'), href: 'https://github.com/Gimanh/taskview-community' },
|
||||
{ kind: 'nav', key: 'docker', icon: 'simple-icons:docker', color: 'zinc', title: t('userMenu.docker'), href: 'https://hub.docker.com/u/gimanhead' },
|
||||
],
|
||||
}
|
||||
|
||||
return [account, appearance, layout, about]
|
||||
})
|
||||
|
||||
return { identity, sections }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<span
|
||||
class="flex items-center justify-center shrink-0 size-9 rounded-xl"
|
||||
:class="tint"
|
||||
>
|
||||
<UIcon
|
||||
:name="icon"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SettingsTint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string
|
||||
color: SettingsTint
|
||||
}>()
|
||||
|
||||
const TILE_TINTS: Record<SettingsTint, string> = {
|
||||
emerald: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400',
|
||||
violet: 'bg-violet-100 text-violet-600 dark:bg-violet-950 dark:text-violet-400',
|
||||
indigo: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950 dark:text-indigo-400',
|
||||
blue: 'bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
|
||||
cyan: 'bg-cyan-100 text-cyan-600 dark:bg-cyan-950 dark:text-cyan-400',
|
||||
amber: 'bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400',
|
||||
sky: 'bg-sky-100 text-sky-600 dark:bg-sky-950 dark:text-sky-400',
|
||||
rose: 'bg-rose-100 text-rose-600 dark:bg-rose-950 dark:text-rose-400',
|
||||
teal: 'bg-teal-100 text-teal-600 dark:bg-teal-950 dark:text-teal-400',
|
||||
zinc: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400',
|
||||
}
|
||||
|
||||
const tint = computed(() => TILE_TINTS[props.color])
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-2xl bg-elevated/40 border border-default px-3 py-2.5 text-left transition-colors hover:bg-error/10 focus-visible:bg-error/10 focus:outline-none"
|
||||
@click="onLogout"
|
||||
>
|
||||
<SettingsIconTile
|
||||
icon="i-lucide-log-out"
|
||||
color="rose"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-error">
|
||||
{{ t('userMenu.logout') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLogout } from '@/composables/useLogout'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
async function onLogout() {
|
||||
const success = await useLogout()
|
||||
if (success) {
|
||||
router.push('/')
|
||||
} else {
|
||||
toast.add({
|
||||
title: t('userMenu.logoutFailed'),
|
||||
description: t('userMenu.logoutFailedDescription'),
|
||||
color: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<ULink
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
:target="isExternal ? '_blank' : undefined"
|
||||
:rel="isExternal ? 'noopener noreferrer' : undefined"
|
||||
class="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-elevated focus-visible:bg-elevated focus:outline-none"
|
||||
>
|
||||
<SettingsIconTile
|
||||
:icon="item.icon"
|
||||
:color="item.color"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<UIcon
|
||||
:name="isExternal ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'"
|
||||
class="size-4 shrink-0 text-dimmed"
|
||||
/>
|
||||
</ULink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SettingsNavRowItem } from '../types'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
item: SettingsNavRowItem
|
||||
}>()
|
||||
|
||||
const isExternal = computed(() => !!props.item.href)
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 rounded-2xl bg-elevated/40 border border-default p-4">
|
||||
<UAvatar
|
||||
:alt="name"
|
||||
icon="i-lucide-user"
|
||||
size="xl"
|
||||
class="ring-2 ring-primary/40"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate text-base font-semibold text-highlighted">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="email && email !== name"
|
||||
class="truncate text-sm text-muted"
|
||||
>
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string
|
||||
email: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
v-if="title"
|
||||
class="px-3 text-xs font-semibold uppercase tracking-wider text-muted"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="overflow-hidden rounded-2xl bg-elevated/40 border border-default divide-y divide-default">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<SettingsIconTile
|
||||
:icon="item.icon"
|
||||
:color="item.color"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<USelect
|
||||
:model-value="item.get()"
|
||||
:items="item.options"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
class="w-40 max-w-[45%]"
|
||||
:ui="{ base: 'rounded-lg' }"
|
||||
@update:model-value="item.set($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SettingsSelectRowItem } from '../types'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
defineProps<{
|
||||
item: SettingsSelectRowItem
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-elevated focus-visible:bg-elevated focus:outline-none"
|
||||
@click="onTap"
|
||||
>
|
||||
<SettingsIconTile
|
||||
icon="i-lucide-info"
|
||||
color="zinc"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
|
||||
{{ t('settings.version') }}
|
||||
</span>
|
||||
<span class="shrink-0 text-sm text-muted tabular-nums">
|
||||
v{{ appVersion }}{{ prodOrDev === 'dev' ? '_d' : '' }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUpdater } from '@/composables/useUpdater'
|
||||
import { $ls } from '@/plugins/axios'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const appVersion = APP_VERSION
|
||||
const prodOrDev = ref<'prod' | 'dev'>('prod')
|
||||
let counter = 0
|
||||
let resetTimer: number
|
||||
let toggleTimer: number
|
||||
|
||||
onMounted(async () => {
|
||||
prodOrDev.value = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
|
||||
})
|
||||
|
||||
async function onTap() {
|
||||
counter++
|
||||
|
||||
if (resetTimer) clearTimeout(resetTimer)
|
||||
if (toggleTimer) clearTimeout(toggleTimer)
|
||||
|
||||
resetTimer = window.setTimeout(() => {
|
||||
counter = 0
|
||||
}, 500)
|
||||
|
||||
if (counter === 7) {
|
||||
toggleTimer = window.setTimeout(async () => {
|
||||
const next = (await $ls.getValue('update_loading')) === 'dev' ? 'prod' : 'dev'
|
||||
await $ls.setValue('update_loading', next)
|
||||
prodOrDev.value = next
|
||||
await useUpdater(true)
|
||||
clearTimeout(toggleTimer)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export type SettingsTint =
|
||||
| 'emerald'
|
||||
| 'violet'
|
||||
| 'indigo'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'amber'
|
||||
| 'sky'
|
||||
| 'rose'
|
||||
| 'teal'
|
||||
| 'zinc'
|
||||
|
||||
type SettingsRowBase = {
|
||||
key: string
|
||||
icon: string
|
||||
color: SettingsTint
|
||||
title: string
|
||||
}
|
||||
|
||||
export type SettingsNavRowItem = SettingsRowBase & {
|
||||
kind: 'nav'
|
||||
to?: RouteLocationRaw
|
||||
href?: string
|
||||
}
|
||||
|
||||
export type SettingsSelectOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingsSelectRowItem = SettingsRowBase & {
|
||||
kind: 'select'
|
||||
options: SettingsSelectOption[]
|
||||
get: () => string
|
||||
set: (value: string) => void
|
||||
}
|
||||
|
||||
export type SettingsRowItem = SettingsNavRowItem | SettingsSelectRowItem
|
||||
|
||||
export type SettingsSection = {
|
||||
key: string
|
||||
title: string
|
||||
items: SettingsRowItem[]
|
||||
}
|
||||
@@ -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,11 @@
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
<SidebarInboxLink />
|
||||
<div class="flex items-stretch gap-2">
|
||||
<SidebarInboxLink class="flex-1" />
|
||||
<SidebarDefaultProjectLink />
|
||||
</div>
|
||||
<SidebarWorkspaceLinks />
|
||||
|
||||
<USeparator />
|
||||
|
||||
@@ -37,6 +41,8 @@ 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'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user