initial commit. Cloned timetracker repository
This commit is contained in:
85
Taskfile.yml
Normal file
85
Taskfile.yml
Normal file
@@ -0,0 +1,85 @@
|
||||
version: "3"
|
||||
|
||||
dotenv: [".env"]
|
||||
|
||||
vars:
|
||||
PROJECT: timetracker
|
||||
BUILD_DIR: ./bin
|
||||
|
||||
REMOTE_USER: root
|
||||
REMOTE_HOST: 192.168.220.30
|
||||
EMAIL_SMTP_PORT: 1025
|
||||
EMAIL_SMTP_HOST: localhost
|
||||
GITEA_SERVICE: gitea_postgres_db
|
||||
GITEA_DB: gitea
|
||||
GITEA_USER: gitea
|
||||
DUMP_FILE_NAME:
|
||||
sh: echo gitea_$(date +%Y_%m_%d__%H_%M_%S).sql
|
||||
GITEA_REMOTE_SERVICE: "gitea_postgres_db"
|
||||
GITEA_REMOTE_DB_NAME: "gitea"
|
||||
GITEA_REMOTE_DB_USER: "gitea"
|
||||
GITEA_REMOTE_DB_PASS: "gitea"
|
||||
DOCKER_CONFIG: |
|
||||
services:
|
||||
{{.DB_SERVICE_NAME}}:
|
||||
image: postgres:alpine
|
||||
container_name: {{.DB_SERVICE_NAME}}
|
||||
environment:
|
||||
POSTGRES_USER: {{.DB_USER}}
|
||||
POSTGRES_PASSWORD: {{.DB_PASSWORD}}
|
||||
POSTGRES_DB: {{.DB_NAME}}
|
||||
ports:
|
||||
- "{{.DB_PORT}}:{{.DB_PORT}}"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
pdf:
|
||||
image: registry.ma-al.com/print-rs:latest
|
||||
command: start_server
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: always
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
environment:
|
||||
- TZ=CET-1CES
|
||||
mailpit:
|
||||
image: axllent/mailpit
|
||||
container_name: mailpit
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mailpit_data:/data/tests
|
||||
ports:
|
||||
- 8025:8025
|
||||
- 1025:1025
|
||||
environment:
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_DATABASE: /data/tests/mailpit.db
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: true
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: true
|
||||
MP_ENABLE_SPAMASSASSIN: postmark
|
||||
MP_VERBOSE: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
mailpit_data:
|
||||
|
||||
|
||||
includes:
|
||||
docker: ./taskfiles/docker.yml
|
||||
dev: ./taskfiles/dev.yml
|
||||
build: ./taskfiles/build.yml
|
||||
db: ./taskfiles/db.yml
|
||||
gitea: ./taskfiles/gitea.yml
|
||||
i18n: ./taskfiles/i18n.yml
|
||||
tpl: ./taskfiles/templates.yml
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: List all available tasks
|
||||
cmds:
|
||||
- task --list
|
||||
8
app/api/embed.go
Normal file
8
app/api/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed openapi.json
|
||||
var ApenapiJson string
|
||||
944
app/api/openapi.json
Normal file
944
app/api/openapi.json
Normal file
@@ -0,0 +1,944 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "timeTracker API",
|
||||
"description": "Authentication and user management API",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "API Support",
|
||||
"email": "support@example.com"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:3000",
|
||||
"description": "Development server"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "Health",
|
||||
"description": "Health check endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Auth",
|
||||
"description": "Authentication endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Languages",
|
||||
"description": "Language and translation endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Protected",
|
||||
"description": "Protected routes requiring authentication"
|
||||
},
|
||||
{
|
||||
"name": "Admin",
|
||||
"description": "Admin-only endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"description": "Application settings and configuration endpoints"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": ["Health"],
|
||||
"summary": "Health check",
|
||||
"description": "Returns the health status of the application",
|
||||
"operationId": "getHealth",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"app": {
|
||||
"type": "string",
|
||||
"example": "timeTracker"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/langs": {
|
||||
"get": {
|
||||
"tags": ["Languages"],
|
||||
"summary": "Get active languages",
|
||||
"description": "Returns a list of all active languages",
|
||||
"operationId": "getLanguages",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Language"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/translations": {
|
||||
"get": {
|
||||
"tags": ["Languages"],
|
||||
"summary": "Get translations",
|
||||
"description": "Returns translations from cache. Supports filtering by lang_id, scope, and components.",
|
||||
"operationId": "getTranslations",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "lang_id",
|
||||
"in": "query",
|
||||
"description": "Filter by language ID",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"in": "query",
|
||||
"description": "Filter by scope (e.g., 'be', 'frontend')",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "components",
|
||||
"in": "query",
|
||||
"description": "Filter by component name",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success"
|
||||
},
|
||||
"translations": {
|
||||
"type": "object",
|
||||
"description": "Translation data keyed by language ID, scope, component, and key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request parameters",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/translations/reload": {
|
||||
"get": {
|
||||
"tags": ["Languages"],
|
||||
"summary": "Reload translations",
|
||||
"description": "Reloads translations from the database into the cache",
|
||||
"operationId": "reloadTranslations",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Translations reloaded successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Translations reloaded successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to reload translations",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "User login",
|
||||
"description": "Authenticate a user with email and password",
|
||||
"operationId": "login",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Login successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Set-Cookie": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "HTTP-only cookies containing access and refresh tokens"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid credentials",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Account inactive or email not verified",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/register": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "User registration",
|
||||
"description": "Register a new user account",
|
||||
"operationId": "register",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegisterRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Registration successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "registration successful, please verify your email"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request or email already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/complete-registration": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Complete registration",
|
||||
"description": "Complete registration after email verification",
|
||||
"operationId": "completeRegistration",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CompleteRegistrationRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Registration completed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/forgot-password": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Request password reset",
|
||||
"description": "Request a password reset email",
|
||||
"operationId": "forgotPassword",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["email"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset email sent if account exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "if an account with that email exists, a password reset link has been sent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/reset-password": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Reset password",
|
||||
"description": "Reset password using reset token",
|
||||
"operationId": "resetPassword",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "password reset successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "User logout",
|
||||
"description": "Clear authentication cookies",
|
||||
"operationId": "logout",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Logout successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "logged out successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Refresh access token",
|
||||
"description": "Get a new access token using refresh token",
|
||||
"operationId": "refreshToken",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"description": "Refresh token from login response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Token refreshed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Refresh token required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid or expired refresh token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/protected/dashboard": {
|
||||
"get": {
|
||||
"tags": ["Protected"],
|
||||
"summary": "Get dashboard data",
|
||||
"description": "Protected route requiring authentication",
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Dashboard data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/UserSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users": {
|
||||
"get": {
|
||||
"tags": ["Admin"],
|
||||
"summary": "Get all users",
|
||||
"description": "Admin-only endpoint for user management",
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of users",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Admin access required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/settings": {
|
||||
"get": {
|
||||
"tags": ["Settings"],
|
||||
"summary": "Get application settings",
|
||||
"description": "Returns public application settings and configuration",
|
||||
"operationId": "getSettings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Settings retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"LoginRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "User's password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password", "confirm_password"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "User's password (min 8 chars, uppercase, lowercase, digit)"
|
||||
},
|
||||
"confirm_password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "Password confirmation"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string",
|
||||
"description": "User's first name"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string",
|
||||
"description": "User's last name"
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"description": "User's preferred language (e.g., 'en', 'pl', 'cs')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CompleteRegistrationRequest": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Email verification token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"required": ["token", "password"],
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Password reset token"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "New password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string",
|
||||
"description": "JWT access token"
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"description": "JWT refresh token"
|
||||
},
|
||||
"token_type": {
|
||||
"type": "string",
|
||||
"example": "Bearer"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer",
|
||||
"description": "Token expiration in seconds"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/UserSession"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"description": "User ID"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["user", "admin"],
|
||||
"description": "User role"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Language": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"description": "Language ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Language name"
|
||||
},
|
||||
"iso_code": {
|
||||
"type": "string",
|
||||
"description": "ISO 639-1 code (e.g., 'en', 'pl')"
|
||||
},
|
||||
"lang_code": {
|
||||
"type": "string",
|
||||
"description": "Full language code (e.g., 'en-US', 'pl-PL')"
|
||||
},
|
||||
"date_format": {
|
||||
"type": "string",
|
||||
"description": "Date format string"
|
||||
},
|
||||
"date_format_short": {
|
||||
"type": "string",
|
||||
"description": "Short date format string"
|
||||
},
|
||||
"rtl": {
|
||||
"type": "boolean",
|
||||
"description": "Right-to-left language"
|
||||
},
|
||||
"is_default": {
|
||||
"type": "boolean",
|
||||
"description": "Is default language"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "Is active"
|
||||
},
|
||||
"flag": {
|
||||
"type": "string",
|
||||
"description": "Flag emoji or code"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SettingsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": {
|
||||
"$ref": "#/components/schemas/AppSettings"
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/components/schemas/ServerSettings"
|
||||
},
|
||||
"auth": {
|
||||
"$ref": "#/components/schemas/AuthSettings"
|
||||
},
|
||||
"features": {
|
||||
"$ref": "#/components/schemas/FeatureFlags"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/components/schemas/VersionInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Application name"
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
"description": "Application environment (e.g., 'development', 'production')"
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"description": "Base URL of the application"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServerSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Server port"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Server host"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jwt_expiration": {
|
||||
"type": "integer",
|
||||
"description": "JWT token expiration in seconds"
|
||||
},
|
||||
"refresh_expiration": {
|
||||
"type": "integer",
|
||||
"description": "Refresh token expiration in seconds"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FeatureFlags": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether email functionality is enabled"
|
||||
},
|
||||
"oauth_google": {
|
||||
"type": "boolean",
|
||||
"description": "Whether Google OAuth is enabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VersionInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Application version"
|
||||
},
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"description": "Git commit hash"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"description": "Build date"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT token obtained from login response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/cmd/main.go
Normal file
41
app/cmd/main.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for version subcommand
|
||||
versionFlag := flag.Bool("version", false, "Show version information")
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
log.Println(version.String())
|
||||
return
|
||||
}
|
||||
|
||||
// Create and setup the server
|
||||
server := web.New()
|
||||
|
||||
// Configure routes
|
||||
if err := server.Setup(); err != nil {
|
||||
log.Fatalf("Failed to setup server: %v", err)
|
||||
}
|
||||
|
||||
// Load translations on startup
|
||||
if err := langs.LangSrv.LoadTranslations(); err != nil {
|
||||
log.Printf("Warning: Failed to load translations on startup: %v", err)
|
||||
} else {
|
||||
log.Println("Translations loaded successfully on startup")
|
||||
}
|
||||
|
||||
// Start the server
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
281
app/config/config.go
Normal file
281
app/config/config.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Auth AuthConfig
|
||||
OAuth OAuthConfig
|
||||
App AppConfig
|
||||
Email EmailConfig
|
||||
I18n I18n
|
||||
Pdf PdfPrinter
|
||||
}
|
||||
|
||||
type I18n struct {
|
||||
Langs []string `env:"I18N_LANGS,en,pl"`
|
||||
}
|
||||
type ServerConfig struct {
|
||||
Port int `env:"SERVER_PORT,3000"`
|
||||
Host string `env:"SERVER_HOST,0.0.0.0"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `env:"DB_HOST,localhost"`
|
||||
Port int `env:"DB_PORT"`
|
||||
User string `env:"DB_USER"`
|
||||
Password string `env:"DB_PASSWORD"`
|
||||
Name string `env:"DB_NAME"`
|
||||
SSLMode string `env:",disable"`
|
||||
MaxIdleConns int `env:",10"`
|
||||
MaxOpenConns int `env:",100"`
|
||||
ConnMaxLifetime time.Duration `env:",1h"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `env:"AUTH_JWT_SECRET"`
|
||||
JWTExpiration int `env:"AUTH_JWT_EXPIRATION"`
|
||||
RefreshExpiration int `env:"AUTH_REFRESH_EXPIRATION"`
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
Google GoogleOAuthConfig
|
||||
}
|
||||
|
||||
type GoogleOAuthConfig struct {
|
||||
ClientID string `env:"OAUTH_GOOGLE_CLIENT_ID"`
|
||||
ClientSecret string `env:"OAUTH_GOOGLE_CLIENT_SECRET"`
|
||||
RedirectURL string `env:"OAUTH_GOOGLE_REDIRECT_URL"`
|
||||
Scopes []string `env:""`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Name string `env:"APP_NAME,Gitea Manager"`
|
||||
Version string `env:"APP_VERSION,1.0.0"`
|
||||
Environment string `env:"APP_ENVIRONMENT,development"`
|
||||
BaseURL string `env:"APP_BASE_URL,http://localhost:5173"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
SMTPHost string `env:"EMAIL_SMTP_HOST,localhost"`
|
||||
SMTPPort int `env:"EMAIL_SMTP_PORT,587"`
|
||||
SMTPUser string `env:"EMAIL_SMTP_USER"`
|
||||
SMTPPassword string `env:"EMAIL_SMTP_PASSWORD"`
|
||||
FromEmail string `env:"EMAIL_FROM,noreply@example.com"`
|
||||
FromName string `env:"EMAIL_FROM_NAME,Gitea Manager"`
|
||||
AdminEmail string `env:"EMAIL_ADMIN,admin@example.com"`
|
||||
Enabled bool `env:"EMAIL_ENABLED,false"`
|
||||
}
|
||||
|
||||
type PdfPrinter struct {
|
||||
ServerUrl string `env:"PDF_SERVER_URL,http://localhost:8000"`
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
|
||||
func init() {
|
||||
if cfg == nil {
|
||||
cfg = load()
|
||||
}
|
||||
}
|
||||
|
||||
func Get() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GetDSN returns the database connection string
|
||||
func (c *DatabaseConfig) GetDSN() string {
|
||||
return fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.User,
|
||||
c.Password,
|
||||
c.Name,
|
||||
c.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
func load() *Config {
|
||||
|
||||
cfg := &Config{}
|
||||
|
||||
err := godotenv.Load(".env")
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env: %s", err.Error(), "")
|
||||
}
|
||||
err = loadEnv(&cfg.Database)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for database : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Server)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for server : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Auth)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for auth : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.OAuth.Google)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for outh google : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.App)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for app : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Email)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for email : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.I18n)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for email : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Pdf)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for email : ", err.Error(), "")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadEnv(dst any) error {
|
||||
v := reflect.ValueOf(dst)
|
||||
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("dst must be pointer to struct")
|
||||
}
|
||||
|
||||
return loadStruct(v.Elem())
|
||||
}
|
||||
|
||||
func loadStruct(v reflect.Value) error {
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := t.Field(i)
|
||||
|
||||
if !field.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
// nested struct
|
||||
if field.Kind() == reflect.Struct && field.Type() != reflect.TypeOf(time.Duration(0)) {
|
||||
if err := loadStruct(field); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
tag := fieldType.Tag.Get("env")
|
||||
key, def := parseEnvTag(tag)
|
||||
|
||||
if key == "" {
|
||||
key = fieldType.Name
|
||||
}
|
||||
|
||||
val, ok := os.LookupEnv(key)
|
||||
|
||||
// fallback to default
|
||||
if !ok && def != nil {
|
||||
val = *def
|
||||
ok = true
|
||||
}
|
||||
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := setValue(field, val, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setValue(field reflect.Value, val string, key string) error {
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
field.SetString(val)
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
// time.Duration
|
||||
if field.Type() == reflect.TypeOf(time.Duration(0)) {
|
||||
d, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("env %s: %w", key, err)
|
||||
}
|
||||
field.SetInt(int64(d))
|
||||
return nil
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("env %s: %w", key, err)
|
||||
}
|
||||
field.SetInt(int64(i))
|
||||
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("env %s: %w", key, err)
|
||||
}
|
||||
field.SetBool(b)
|
||||
|
||||
case reflect.Slice:
|
||||
if field.Type().Elem().Kind() == reflect.String {
|
||||
// Split by comma and trim whitespace
|
||||
parts := strings.Split(val, ",")
|
||||
slice := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
slice = append(slice, p)
|
||||
}
|
||||
}
|
||||
field.Set(reflect.ValueOf(slice))
|
||||
} else {
|
||||
return fmt.Errorf("unsupported slice type %s for env %s", field.Type().Elem().Kind(), key)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported type %s for env %s", field.Kind(), key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEnvTag(tag string) (key string, def *string) {
|
||||
if tag == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(tag, ",", 2)
|
||||
key = parts[0]
|
||||
|
||||
if len(parts) == 2 {
|
||||
return key, &parts[1] // Returns "en,pl,de" for slices - setValue handles the split
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
109
app/db/postgres.go
Normal file
109
app/db/postgres.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func init() {
|
||||
if DB == nil {
|
||||
dbconn, err := newPostgresDB(&config.Get().Database)
|
||||
if err != nil {
|
||||
slog.Error("⚠️ No connection to database was possible to establish", "error", err.Error())
|
||||
}
|
||||
DB = dbconn
|
||||
}
|
||||
}
|
||||
|
||||
func Get() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// newPostgresDB creates a new PostgreSQL database connection
|
||||
func newPostgresDB(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
dsn := cfg.GetDSN()
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Error),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
|
||||
// Connection pool settings
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
|
||||
log.Println("✓ Database connection established successfully")
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// // RunMigrations runs all database migrations
|
||||
// func RunMigrations() error {
|
||||
// if DB == nil {
|
||||
// return fmt.Errorf("database connection not established")
|
||||
// }
|
||||
|
||||
// log.Println("Running database migrations...")
|
||||
|
||||
// // Add your models here for AutoMigrate
|
||||
// // Example: err := db.AutoMigrate(&model.Customer{})
|
||||
|
||||
// err := DB.AutoMigrate(&model.Customer{})
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to run migrations: %w", err)
|
||||
// }
|
||||
|
||||
// log.Println("✓ Database migrations completed successfully")
|
||||
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// // SeedAdminUser creates a default admin user if one doesn't exist
|
||||
// // Call this function with admin credentials after migrations
|
||||
// func SeedAdminUser(adminEmail, adminPassword string) error {
|
||||
// log.Println("✓ Admin seeding ready - implement with your User model")
|
||||
|
||||
// // Example implementation when you have a User model:
|
||||
// // var count int64
|
||||
// // db.Model(&model.User{}).Where("role = ?", "admin").Count(&count)
|
||||
// // if count > 0 {
|
||||
// // log.Println("✓ Admin user already exists")
|
||||
// // return nil
|
||||
// // }
|
||||
// // hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||
// // if err != nil {
|
||||
// // return fmt.Errorf("failed to hash password: %w", err)
|
||||
// // }
|
||||
// // admin := model.User{
|
||||
// // Email: adminEmail,
|
||||
// // Password: string(hashedPassword),
|
||||
// // Role: "admin",
|
||||
// // IsActive: true,
|
||||
// // }
|
||||
// // if err := db.Create(&admin).Error; err != nil {
|
||||
// // return err
|
||||
// // }
|
||||
// // log.Printf("✓ Created admin user: %s", adminEmail)
|
||||
|
||||
// // Suppress unused variable warning
|
||||
// _, _ = bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||
|
||||
// return nil
|
||||
// }
|
||||
11
app/delivery/handler/auth.go
Normal file
11
app/delivery/handler/auth.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandlerRoutes registers all auth routes
|
||||
func AuthHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
return public.AuthHandlerRoutes(r)
|
||||
}
|
||||
11
app/delivery/handler/repo.go
Normal file
11
app/delivery/handler/repo.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandlerRoutes registers all auth routes
|
||||
func RepoHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
return public.RepoHandlerRoutes(r)
|
||||
}
|
||||
118
app/delivery/middleware/auth.go
Normal file
118
app/delivery/middleware/auth.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/authService"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthMiddleware creates authentication middleware
|
||||
func AuthMiddleware() fiber.Handler {
|
||||
authService := authService.NewAuthService()
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
// Get token from Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// Try to get from cookie
|
||||
authHeader = c.Cookies("access_token")
|
||||
if authHeader == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "authorization token required",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Extract token from "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid authorization header format",
|
||||
})
|
||||
}
|
||||
authHeader = parts[1]
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := authService.ValidateToken(authHeader)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
user, err := authService.GetUserByID(claims.UserID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "user not found",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "user account is inactive",
|
||||
})
|
||||
}
|
||||
|
||||
// Set user in context
|
||||
c.Locals("user", user.ToSession())
|
||||
c.Locals("userID", user.ID)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin creates admin-only middleware
|
||||
func RequireAdmin() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
user := c.Locals("user")
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
userSession, ok := user.(*model.UserSession)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "invalid user session",
|
||||
})
|
||||
}
|
||||
|
||||
if userSession.Role != model.RoleAdmin {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "admin access required",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID extracts user ID from context
|
||||
func GetUserID(c fiber.Ctx) uint {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
// GetUser extracts user from context
|
||||
func GetUser(c fiber.Ctx) *model.UserSession {
|
||||
user, ok := c.Locals("user").(*model.UserSession)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// GetConfig returns the app config
|
||||
func GetConfig() *config.Config {
|
||||
return config.Get()
|
||||
}
|
||||
18
app/delivery/middleware/cors.go
Normal file
18
app/delivery/middleware/cors.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
// CORSMiddleware creates CORS middleware
|
||||
func CORSMiddleware() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
c.Set("Access-Control-Allow-Origin", "*")
|
||||
c.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if c.Method() == "OPTIONS" {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
114
app/delivery/middleware/language.go
Normal file
114
app/delivery/middleware/language.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// LanguageMiddleware discovers client's language and stores it in context
|
||||
// Priority: Query param > Cookie > Accept-Language header > Default language
|
||||
func LanguageMiddleware() fiber.Handler {
|
||||
langService := langs.LangSrv
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
var langID uint
|
||||
|
||||
// 1. Check query parameter
|
||||
langIDStr := c.Query("lang_id", "")
|
||||
if langIDStr != "" {
|
||||
if id, err := strconv.ParseUint(langIDStr, 10, 32); err == nil {
|
||||
langID = uint(id)
|
||||
if langID > 0 {
|
||||
lang, err := langService.GetLanguageById(langID)
|
||||
if err == nil {
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", lang)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check cookie
|
||||
cookieLang := c.Cookies("lang_id", "")
|
||||
if cookieLang != "" {
|
||||
if id, err := strconv.ParseUint(cookieLang, 10, 32); err == nil {
|
||||
langID = uint(id)
|
||||
if langID > 0 {
|
||||
lang, err := langService.GetLanguageById(langID)
|
||||
if err == nil {
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", lang)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check Accept-Language header
|
||||
acceptLang := c.Get("Accept-Language", "")
|
||||
if acceptLang != "" {
|
||||
// Parse the Accept-Language header (e.g., "en-US,en;q=0.9,pl;q=0.8")
|
||||
isoCode := parseAcceptLanguage(acceptLang)
|
||||
if isoCode != "" {
|
||||
lang, err := langService.GetLanguageByISOCode(isoCode)
|
||||
if err == nil && lang != nil {
|
||||
langID = uint(lang.ID)
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", lang)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall back to default language
|
||||
defaultLang, err := langService.GetDefaultLanguage()
|
||||
if err == nil && defaultLang != nil {
|
||||
langID = uint(defaultLang.ID)
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", defaultLang)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// parseAcceptLanguage extracts the primary language ISO code from Accept-Language header
|
||||
func parseAcceptLanguage(header string) string {
|
||||
// Split by comma
|
||||
parts := strings.Split(header, ",")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get the first part (highest priority)
|
||||
first := strings.TrimSpace(parts[0])
|
||||
if first == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove any quality value (e.g., ";q=0.9")
|
||||
if idx := strings.Index(first, ";"); idx != -1 {
|
||||
first = strings.TrimSpace(first[:idx])
|
||||
}
|
||||
|
||||
// Handle cases like "en-US" or "en"
|
||||
// Return the primary language code (first part before dash)
|
||||
if idx := strings.Index(first, "-"); idx != -1 {
|
||||
return strings.ToLower(first[:idx])
|
||||
}
|
||||
|
||||
return strings.ToLower(first)
|
||||
}
|
||||
|
||||
// GetLanguageID extracts language ID from context
|
||||
func GetLanguageID(c fiber.Ctx) uint {
|
||||
langID, ok := c.Locals("langID").(uint)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return langID
|
||||
}
|
||||
177
app/delivery/web/init.go
Normal file
177
app/delivery/web/init.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/handler"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/middleware"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
|
||||
|
||||
// "github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
// "github.com/gofiber/fiber/v3/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
)
|
||||
|
||||
// Server represents the web server
|
||||
type Server struct {
|
||||
app *fiber.App
|
||||
cfg *config.Config
|
||||
api fiber.Router
|
||||
}
|
||||
|
||||
// App returns the fiber app
|
||||
func (s *Server) App() *fiber.App {
|
||||
return s.app
|
||||
}
|
||||
|
||||
// Cfg returns the config
|
||||
func (s *Server) Cfg() *config.Config {
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// New creates a new server instance
|
||||
func New() *Server {
|
||||
return &Server{
|
||||
app: fiber.New(fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
}),
|
||||
cfg: config.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// Setup configures the server with routes and middleware
|
||||
func (s *Server) Setup() error {
|
||||
// Global middleware
|
||||
s.app.Use(recover.New())
|
||||
s.app.Use(logger.New())
|
||||
|
||||
// CORS middleware
|
||||
s.app.Use(middleware.CORSMiddleware())
|
||||
|
||||
// Language middleware - discovers client's language and stores in context
|
||||
s.app.Use(middleware.LanguageMiddleware())
|
||||
|
||||
// initialize healthcheck
|
||||
public.InitHealth(s.App(), s.Cfg())
|
||||
|
||||
// serve favicon
|
||||
public.Favicon(s.app, s.cfg)
|
||||
|
||||
// API routes
|
||||
s.api = s.app.Group("/api/v1")
|
||||
|
||||
// initialize swagger endpoints
|
||||
public.InitSwagger(s.App())
|
||||
|
||||
// Auth routes (public)
|
||||
auth := s.api.Group("/auth")
|
||||
handler.AuthHandlerRoutes(auth)
|
||||
|
||||
// Repo routes (public)
|
||||
repo := s.api.Group("/repo")
|
||||
repo.Use(middleware.AuthMiddleware())
|
||||
handler.RepoHandlerRoutes(repo)
|
||||
|
||||
// Protected routes example
|
||||
protected := s.api.Group("/restricted")
|
||||
protected.Use(middleware.AuthMiddleware())
|
||||
protected.Get("/dashboard", func(c fiber.Ctx) error {
|
||||
user := middleware.GetUser(c)
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Welcome to the protected area",
|
||||
"user": user,
|
||||
})
|
||||
})
|
||||
|
||||
// Admin routes example
|
||||
admin := s.api.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware())
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.Get("/users", func(c fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Admin area - user management",
|
||||
})
|
||||
})
|
||||
|
||||
public.NewLangHandler().InitLanguage(s.api, s.cfg)
|
||||
|
||||
// Settings endpoint
|
||||
public.NewSettingsHandler().InitSettings(s.api, s.cfg)
|
||||
|
||||
// keep this at the end because its wilderange
|
||||
public.InitBo(s.App())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the server
|
||||
func (s *Server) Run() error {
|
||||
// Run database migrations
|
||||
// if err := db.RunMigrations(); err != nil {
|
||||
// log.Printf("⚠️ Database migrations failed: %v", err)
|
||||
// } else {
|
||||
// log.Println("✓ Database migrations completed")
|
||||
// }
|
||||
|
||||
// // Seed admin user
|
||||
// if err := db.SeedAdminUser("admin@example.com", "admin123"); err != nil {
|
||||
// log.Printf("⚠️ Admin user seeding failed: %v", err)
|
||||
// }
|
||||
|
||||
addr := s.cfg.Server.Host + ":" + strconv.Itoa(s.cfg.Server.Port)
|
||||
log.Printf("Starting server on %s", addr)
|
||||
log.Printf("Swagger UI available at http://%s/swagger/index.html", addr)
|
||||
log.Printf("OpenAPI JSON available at http://%s/openapi.json", addr)
|
||||
|
||||
go func() {
|
||||
if err := s.app.Listen(":3000"); err != nil {
|
||||
log.Println("Server stopped:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
signal.Notify(
|
||||
quit,
|
||||
syscall.SIGINT, // Ctrl+C
|
||||
syscall.SIGTERM, // docker stop
|
||||
)
|
||||
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// graceful shutdown with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.app.ShutdownWithContext(ctx); err != nil {
|
||||
log.Fatal("Shutdown error:", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited cleanly")
|
||||
return nil
|
||||
}
|
||||
|
||||
// customErrorHandler handles errors
|
||||
func customErrorHandler(c fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
416
app/delivery/web/public/auth.go
Normal file
416
app/delivery/web/public/auth.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/middleware"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/authService"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication endpoints
|
||||
type AuthHandler struct {
|
||||
authService *authService.AuthService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new AuthHandler instance
|
||||
func NewAuthHandler() *AuthHandler {
|
||||
authService := authService.NewAuthService()
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
config: config.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// AuthHandlerRoutes registers all auth routes
|
||||
func AuthHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
handler := NewAuthHandler()
|
||||
|
||||
r.Post("/login", handler.Login)
|
||||
r.Post("/register", handler.Register)
|
||||
r.Post("/complete-registration", handler.CompleteRegistration)
|
||||
r.Post("/forgot-password", handler.ForgotPassword)
|
||||
r.Post("/reset-password", handler.ResetPassword)
|
||||
r.Post("/logout", handler.Logout)
|
||||
r.Post("/refresh", handler.RefreshToken)
|
||||
|
||||
// Google OAuth2
|
||||
r.Get("/google", handler.GoogleLogin)
|
||||
r.Get("/google/callback", handler.GoogleCallback)
|
||||
|
||||
authProtected := r.Group("", middleware.AuthMiddleware())
|
||||
authProtected.Get("/me", handler.Me)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c fiber.Ctx) error {
|
||||
var req model.LoginRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Email == "" || req.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
response, rawRefreshToken, err := h.authService.Login(&req)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set cookies for web-based authentication
|
||||
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// setAuthCookies sets the access token (HTTPOnly) and refresh token (HTTPOnly) cookies,
|
||||
// plus a non-HTTPOnly is_authenticated flag cookie for frontend state detection.
|
||||
func (h *AuthHandler) setAuthCookies(c fiber.Ctx, accessToken, rawRefreshToken string) {
|
||||
isProduction := h.config.App.Environment == "production"
|
||||
|
||||
// HTTPOnly access token cookie — not readable by JS, protects against XSS
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "access_token",
|
||||
Value: accessToken,
|
||||
Expires: time.Now().Add(time.Duration(h.config.Auth.JWTExpiration) * time.Second),
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
|
||||
// HTTPOnly refresh token cookie — opaque, stored as hash in DB
|
||||
if rawRefreshToken != "" {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: rawRefreshToken,
|
||||
Expires: time.Now().Add(time.Duration(h.config.Auth.RefreshExpiration) * time.Second),
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// Non-HTTPOnly flag cookie — readable by JS to detect auth state.
|
||||
// Contains no sensitive data; actual auth is enforced by the HTTPOnly access_token cookie.
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "is_authenticated",
|
||||
Value: "1",
|
||||
Expires: time.Now().Add(time.Duration(h.config.Auth.JWTExpiration) * time.Second),
|
||||
HTTPOnly: false,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// clearAuthCookies expires all auth-related cookies
|
||||
func (h *AuthHandler) clearAuthCookies(c fiber.Ctx) {
|
||||
isProduction := h.config.App.Environment == "production"
|
||||
past := time.Now().Add(-time.Hour)
|
||||
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "access_token",
|
||||
Value: "",
|
||||
Expires: past,
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: "",
|
||||
Expires: past,
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "is_authenticated",
|
||||
Value: "",
|
||||
Expires: past,
|
||||
HTTPOnly: false,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// ForgotPassword handles password reset request
|
||||
func (h *AuthHandler) ForgotPassword(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Email string `json:"email" form:"email"`
|
||||
}
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if req.Email == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrEmailRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Request password reset - always return success to prevent email enumeration
|
||||
err := h.authService.RequestPasswordReset(req.Email)
|
||||
if err != nil {
|
||||
log.Printf("Password reset request error: %v", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_if_account_exists"),
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword handles password reset completion
|
||||
func (h *AuthHandler) ResetPassword(c fiber.Ctx) error {
|
||||
var req model.ResetPasswordRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Token == "" || req.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrTokenPasswordRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Reset password (also revokes all refresh tokens for the user)
|
||||
err := h.authService.ResetPassword(req.Token, req.Password)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_password_reset_successfully"),
|
||||
})
|
||||
}
|
||||
|
||||
// Logout handles user logout — revokes the refresh token from DB and clears all cookies
|
||||
func (h *AuthHandler) Logout(c fiber.Ctx) error {
|
||||
// Revoke the refresh token from the database
|
||||
rawRefreshToken := c.Cookies("refresh_token")
|
||||
if rawRefreshToken != "" {
|
||||
h.authService.RevokeRefreshToken(rawRefreshToken)
|
||||
}
|
||||
|
||||
// Clear all auth cookies
|
||||
h.clearAuthCookies(c)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_logged_out_successfully"),
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshToken handles token refresh — validates opaque refresh token, rotates it, issues new access token
|
||||
func (h *AuthHandler) RefreshToken(c fiber.Ctx) error {
|
||||
// Get refresh token from HTTPOnly cookie (preferred) or request body (fallback for API clients)
|
||||
rawRefreshToken := c.Cookies("refresh_token")
|
||||
if rawRefreshToken == "" {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := c.Bind().Body(&body); err == nil {
|
||||
rawRefreshToken = body.RefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
if rawRefreshToken == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrRefreshTokenRequired),
|
||||
})
|
||||
}
|
||||
|
||||
response, newRawRefreshToken, err := h.authService.RefreshToken(rawRefreshToken)
|
||||
if err != nil {
|
||||
// If refresh token is invalid/expired, clear cookies
|
||||
h.clearAuthCookies(c)
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set new cookies (rotated refresh token + new access token)
|
||||
h.setAuthCookies(c, response.AccessToken, newRawRefreshToken)
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// Me returns the current user info
|
||||
func (h *AuthHandler) Me(c fiber.Ctx) error {
|
||||
user := c.Locals("user")
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrNotAuthenticated),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// Register handles user registration
|
||||
func (h *AuthHandler) Register(c fiber.Ctx) error {
|
||||
var req model.RegisterRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.FirstName == "" || req.LastName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrFirstLastNameRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Email == "" || req.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Attempt registration
|
||||
err := h.authService.Register(&req)
|
||||
if err != nil {
|
||||
log.Printf("Register error: %v", err)
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_registration_successful"),
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteRegistration handles completion of registration with password
|
||||
func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error {
|
||||
var req model.CompleteRegistrationRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrTokenRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Attempt to complete registration
|
||||
response, rawRefreshToken, err := h.authService.CompleteRegistration(&req)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set cookies for web-based authentication
|
||||
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
// GoogleLogin redirects the user to Google's OAuth2 consent page
|
||||
func (h *AuthHandler) GoogleLogin(c fiber.Ctx) error {
|
||||
// Generate a random state token and store it in a short-lived cookie
|
||||
state, err := h.authService.GenerateOAuthState()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": i18n.T_(c, "error.err_internal_server_error"),
|
||||
})
|
||||
}
|
||||
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "oauth_state",
|
||||
Value: state,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
HTTPOnly: true,
|
||||
Secure: h.config.App.Environment == "production",
|
||||
SameSite: "Lax",
|
||||
})
|
||||
|
||||
url := h.authService.GetGoogleAuthURL(state)
|
||||
return c.Redirect().To(url)
|
||||
}
|
||||
|
||||
// GoogleCallback handles the OAuth2 callback from Google
|
||||
func (h *AuthHandler) GoogleCallback(c fiber.Ctx) error {
|
||||
// Validate state to prevent CSRF
|
||||
cookieState := c.Cookies("oauth_state")
|
||||
queryState := c.Query("state")
|
||||
if cookieState == "" || cookieState != queryState {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": i18n.T_(c, "error.err_invalid_token"),
|
||||
})
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "oauth_state",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-time.Hour),
|
||||
HTTPOnly: true,
|
||||
Secure: h.config.App.Environment == "production",
|
||||
SameSite: "Lax",
|
||||
})
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": i18n.T_(c, "error.err_invalid_body"),
|
||||
})
|
||||
}
|
||||
|
||||
response, rawRefreshToken, err := h.authService.HandleGoogleCallback(code)
|
||||
if err != nil {
|
||||
log.Printf("Google OAuth callback error: %v", err)
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set cookies for web-based authentication (including is_authenticated flag)
|
||||
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
||||
|
||||
// Redirect to the locale-prefixed charts page after successful Google login.
|
||||
// The user's preferred language is stored in the auth response; fall back to "en".
|
||||
lang := response.User.Lang
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
return c.Redirect().To(h.config.App.BaseURL + "/" + lang + "/chart")
|
||||
}
|
||||
26
app/delivery/web/public/bo.go
Normal file
26
app/delivery/web/public/bo.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/assets"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
)
|
||||
|
||||
func InitBo(app *fiber.App) {
|
||||
// static files
|
||||
app.Get("/*", static.New("", static.Config{
|
||||
FS: assets.FS(),
|
||||
// Browse: true,
|
||||
MaxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
}))
|
||||
|
||||
app.Get("/*", static.New("", static.Config{
|
||||
FS: assets.FSDist(),
|
||||
// Browse: true,
|
||||
MaxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
}))
|
||||
|
||||
app.Get("/*", func(c fiber.Ctx) error {
|
||||
return c.SendFile("./assets/public/dist/index.html")
|
||||
})
|
||||
}
|
||||
17
app/delivery/web/public/favicon.go
Normal file
17
app/delivery/web/public/favicon.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/assets"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func Favicon(app *fiber.App, cfg *config.Config) {
|
||||
// Favicon check endpoint
|
||||
app.Get("/favicon.ico", func(c fiber.Ctx) error {
|
||||
return c.SendFile("img/favicon.ico", fiber.SendFile{
|
||||
FS: assets.FS(),
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
20
app/delivery/web/public/health.go
Normal file
20
app/delivery/web/public/health.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func InitHealth(app *fiber.App, cfg *config.Config) {
|
||||
// Health check endpoint
|
||||
app.Get("/health", func(c fiber.Ctx) error {
|
||||
// emailService.NewEmailService().SendVerificationEmail("goc_daniel@ma-al.com", "jakis_token", c.BaseURL(), "en")
|
||||
// emailService.NewEmailService().SendPasswordResetEmail("goc_daniel@ma-al.com", "jakis_token", c.BaseURL(), "en")
|
||||
// emailService.NewEmailService().SendNewUserAdminNotification("goc_daniel@ma-al.com", "admin", c.BaseURL())
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"app": cfg.App.Name,
|
||||
"version": cfg.App.Version,
|
||||
})
|
||||
})
|
||||
}
|
||||
48
app/delivery/web/public/languages.go
Normal file
48
app/delivery/web/public/languages.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type LangHandler struct {
|
||||
service langs.LangService
|
||||
}
|
||||
|
||||
func NewLangHandler() *LangHandler {
|
||||
return &LangHandler{
|
||||
service: *langs.LangSrv,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LangHandler) InitLanguage(api fiber.Router, cfg *config.Config) {
|
||||
|
||||
api.Get("langs", h.GetLanguages)
|
||||
api.Get("translations", h.GetTranslations)
|
||||
api.Get("translations/reload", h.ReloadTranslations)
|
||||
}
|
||||
|
||||
func (h *LangHandler) GetLanguages(c fiber.Ctx) error {
|
||||
return c.JSON(h.service.GetActive(c))
|
||||
}
|
||||
|
||||
func (h *LangHandler) GetTranslations(c fiber.Ctx) error {
|
||||
langIDStr := c.Query("lang_id", "0")
|
||||
langID, _ := strconv.Atoi(langIDStr)
|
||||
scope := c.Query("scope", "")
|
||||
componentsStr := c.Query("components", "")
|
||||
|
||||
var components []string
|
||||
if componentsStr != "" {
|
||||
components = []string{componentsStr}
|
||||
}
|
||||
|
||||
return c.JSON(h.service.GetTranslations(c, uint(langID), scope, components))
|
||||
}
|
||||
|
||||
func (h *LangHandler) ReloadTranslations(c fiber.Ctx) error {
|
||||
return c.JSON(h.service.ReloadTranslationsResponse(c))
|
||||
}
|
||||
179
app/delivery/web/public/repo.go
Normal file
179
app/delivery/web/public/repo.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/repoService"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// RepoHandler handles endpoints asking for repository data (to create charts)
|
||||
type RepoHandler struct {
|
||||
repoService *repoService.RepoService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewRepoHandler creates a new RepoHandler instance
|
||||
func NewRepoHandler() *RepoHandler {
|
||||
repoService := repoService.New()
|
||||
return &RepoHandler{
|
||||
repoService: repoService,
|
||||
config: config.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// RepoHandlerRoutes registers all repo routes
|
||||
func RepoHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
handler := NewRepoHandler()
|
||||
|
||||
r.Get("/get-repos", handler.GetRepoIDs)
|
||||
r.Get("/get-years", handler.GetYears)
|
||||
r.Get("/get-quarters", handler.GetQuarters)
|
||||
r.Get("/get-issues", handler.GetIssues)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetRepoIDs(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
response, err := h.repoService.GetRepositoriesForUser(userID)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetYears(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
repoID_attribute := c.Query("repoID")
|
||||
repoID, err := strconv.Atoi(repoID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
response, err := h.repoService.GetYearsForUser(userID, uint(repoID))
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetQuarters(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
repoID_attribute := c.Query("repoID")
|
||||
repoID, err := strconv.Atoi(repoID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
year_attribute := c.Query("year")
|
||||
year, err := strconv.Atoi(year_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadYearAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadYearAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
response, err := h.repoService.GetQuartersForUser(userID, uint(repoID), uint(year))
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetIssues(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
repoID_attribute := c.Query("repoID")
|
||||
repoID, err := strconv.Atoi(repoID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
year_attribute := c.Query("year")
|
||||
year, err := strconv.Atoi(year_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadYearAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadYearAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
quarter_attribute := c.Query("quarter")
|
||||
quarter, err := strconv.Atoi(quarter_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadQuarterAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadQuarterAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
page_number_attribute := c.Query("page_number")
|
||||
page_number, err := strconv.Atoi(page_number_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadPaging)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadPaging),
|
||||
})
|
||||
}
|
||||
|
||||
elements_per_page_attribute := c.Query("quarter")
|
||||
elements_per_page, err := strconv.Atoi(elements_per_page_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadPaging)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadPaging),
|
||||
})
|
||||
}
|
||||
|
||||
var paging pagination.Paging
|
||||
paging.Page = uint(page_number)
|
||||
paging.Elements = uint(elements_per_page)
|
||||
|
||||
response, err := h.repoService.GetIssuesForUser(userID, uint(repoID), uint(year), uint(quarter), paging)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
91
app/delivery/web/public/settings.go
Normal file
91
app/delivery/web/public/settings.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/nullable"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/response"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/version"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// SettingsResponse represents the settings endpoint response
|
||||
type SettingsResponse struct {
|
||||
App AppSettings `json:"app"`
|
||||
Server ServerSettings `json:"server"`
|
||||
Auth AuthSettings `json:"auth"`
|
||||
Features FeatureFlags `json:"features"`
|
||||
Version version.Info `json:"version"`
|
||||
}
|
||||
|
||||
// AppSettings represents app configuration
|
||||
type AppSettings struct {
|
||||
Name string `json:"name"`
|
||||
Environment string `json:"environment"`
|
||||
BaseURL string `json:"base_url"`
|
||||
PasswordRegex string `json:"password_regex"`
|
||||
// Config config.Config `json:"config"`
|
||||
}
|
||||
|
||||
// ServerSettings represents server configuration (non-sensitive)
|
||||
type ServerSettings struct {
|
||||
Port int `json:"port"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
// AuthSettings represents auth configuration (non-sensitive)
|
||||
type AuthSettings struct {
|
||||
JWTExpiration int `json:"jwt_expiration"`
|
||||
RefreshExpiration int `json:"refresh_expiration"`
|
||||
}
|
||||
|
||||
// FeatureFlags represents feature flags
|
||||
type FeatureFlags struct {
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
OAuthGoogle bool `json:"oauth_google"`
|
||||
}
|
||||
|
||||
// SettingsHandler handles settings/config endpoints
|
||||
type SettingsHandler struct{}
|
||||
|
||||
// NewSettingsHandler creates a new settings handler
|
||||
func NewSettingsHandler() *SettingsHandler {
|
||||
return &SettingsHandler{}
|
||||
}
|
||||
|
||||
// InitSettings initializes the settings routes
|
||||
func (h *SettingsHandler) InitSettings(api fiber.Router, cfg *config.Config) {
|
||||
settings := api.Group("/settings")
|
||||
settings.Get("", h.GetSettings(cfg))
|
||||
}
|
||||
|
||||
// GetSettings returns all settings/config
|
||||
func (h *SettingsHandler) GetSettings(cfg *config.Config) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
settings := SettingsResponse{
|
||||
App: AppSettings{
|
||||
Name: cfg.App.Name,
|
||||
Environment: cfg.App.Environment,
|
||||
BaseURL: cfg.App.BaseURL,
|
||||
PasswordRegex: constdata.PASSWORD_VALIDATION_REGEX,
|
||||
// Config: *config.Get(),
|
||||
},
|
||||
Server: ServerSettings{
|
||||
Port: cfg.Server.Port,
|
||||
Host: cfg.Server.Host,
|
||||
},
|
||||
Auth: AuthSettings{
|
||||
JWTExpiration: cfg.Auth.JWTExpiration,
|
||||
RefreshExpiration: cfg.Auth.RefreshExpiration,
|
||||
},
|
||||
Features: FeatureFlags{
|
||||
EmailEnabled: cfg.Email.Enabled,
|
||||
OAuthGoogle: cfg.OAuth.Google.ClientID != "",
|
||||
},
|
||||
Version: version.GetInfo(),
|
||||
}
|
||||
|
||||
return c.JSON(response.Make(c, fiber.StatusOK, nullable.GetNil(settings), nullable.GetNil(0), i18n.T_(c, response.Message_OK)))
|
||||
}
|
||||
}
|
||||
60
app/delivery/web/public/swagger.go
Normal file
60
app/delivery/web/public/swagger.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/api"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
)
|
||||
|
||||
func InitSwagger(app *fiber.App) {
|
||||
// Swagger - serve OpenAPI JSON
|
||||
app.Get("/openapi.json", func(c fiber.Ctx) error {
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.SendString(api.ApenapiJson)
|
||||
})
|
||||
|
||||
// Swagger UI HTML
|
||||
app.Get("/swagger", func(c fiber.Ctx) error {
|
||||
return c.Redirect().Status(fiber.StatusFound).To("/swagger/index.html")
|
||||
})
|
||||
|
||||
app.Get("/swagger/index.html", func(c fiber.Ctx) error {
|
||||
c.Set("Content-Type", "text/html")
|
||||
return c.SendString(swaggerHTML)
|
||||
})
|
||||
|
||||
// Serve Swagger assets
|
||||
app.Get("/swagger/assets", static.New("app/api/swagger/assets"))
|
||||
}
|
||||
|
||||
// Embedded Swagger UI HTML (minimal version)
|
||||
var swaggerHTML = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Documentation</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" charset="UTF-8"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
68
app/langs/langs.go
Normal file
68
app/langs/langs.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package langs_repo
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/db"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
type LangsRepo struct{}
|
||||
|
||||
func New() *LangsRepo {
|
||||
return &LangsRepo{}
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetActive() ([]view.Language, error) {
|
||||
langs := []view.Language{}
|
||||
err := db.DB.Model(model.Language{}).Find(&langs, model.Language{Active: true}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langs, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetAllTranslations() ([]model.Translation, error) {
|
||||
var translations []model.Translation
|
||||
err := db.DB.Preload("Language").Preload("Scope").Preload("Component").Find(&translations).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetTranslationsByLangID(langID uint) ([]model.Translation, error) {
|
||||
var translations []model.Translation
|
||||
err := db.DB.Preload("Language").Preload("Scope").Preload("Component").
|
||||
Where("lang_id = ?", langID).Find(&translations).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetDefault() (*view.Language, error) {
|
||||
var lang view.Language
|
||||
err := db.DB.Model(model.Language{}).Where("is_default = ?", true).First(&lang).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lang, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetByISOCode(isoCode string) (*view.Language, error) {
|
||||
var lang view.Language
|
||||
err := db.DB.Model(model.Language{}).First(&lang, model.Language{ISOCode: isoCode}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lang, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetById(id uint) (*view.Language, error) {
|
||||
var lang view.Language
|
||||
err := db.DB.Model(model.Language{}).First(&lang, model.Language{ID: id}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lang, nil
|
||||
}
|
||||
144
app/model/customer.go
Normal file
144
app/model/customer.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type Customer struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email"`
|
||||
Password string `gorm:"size:255" json:"-"` // Hashed password, not exposed in JSON
|
||||
FirstName string `gorm:"size:100" json:"first_name"`
|
||||
LastName string `gorm:"size:100" json:"last_name"`
|
||||
Role CustomerRole `gorm:"type:varchar(20);default:'user'" json:"role"`
|
||||
Provider AuthProvider `gorm:"type:varchar(20);default:'local'" json:"provider"`
|
||||
ProviderID string `gorm:"size:255" json:"provider_id,omitempty"` // ID from OAuth provider
|
||||
AvatarURL string `gorm:"size:500" json:"avatar_url,omitempty"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
EmailVerified bool `gorm:"default:false" json:"email_verified"`
|
||||
EmailVerificationToken string `gorm:"size:255" json:"-"`
|
||||
EmailVerificationExpires *time.Time `json:"-"`
|
||||
PasswordResetToken string `gorm:"size:255" json:"-"`
|
||||
PasswordResetExpires *time.Time `json:"-"`
|
||||
LastPasswordResetRequest *time.Time `json:"-"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
Lang string `gorm:"size:10;default:'en'" json:"lang"` // User's preferred language
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// CustomerRole represents the role of a user
|
||||
type CustomerRole string
|
||||
|
||||
const (
|
||||
RoleUser CustomerRole = "user"
|
||||
RoleAdmin CustomerRole = "admin"
|
||||
)
|
||||
|
||||
// AuthProvider represents the authentication provider
|
||||
type AuthProvider string
|
||||
|
||||
const (
|
||||
ProviderLocal AuthProvider = "local"
|
||||
ProviderGoogle AuthProvider = "google"
|
||||
)
|
||||
|
||||
// TableName specifies the table name for User model
|
||||
func (Customer) TableName() string {
|
||||
return "customers"
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (u *Customer) IsAdmin() bool {
|
||||
return u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
// CanManageUsers checks if the user can manage other users
|
||||
func (u *Customer) CanManageUsers() bool {
|
||||
return u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
// FullName returns the user's full name
|
||||
func (u *Customer) FullName() string {
|
||||
if u.FirstName == "" && u.LastName == "" {
|
||||
return u.Email
|
||||
}
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
|
||||
// UserSession represents a user session for JWT claims
|
||||
type UserSession struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role CustomerRole `json:"role"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
// ToSession converts User to UserSession
|
||||
func (u *Customer) ToSession() *UserSession {
|
||||
return &UserSession{
|
||||
UserID: u.ID,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
Lang: u.Lang,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequest represents the login form data
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" form:"email"`
|
||||
Password string `json:"password" form:"password"`
|
||||
}
|
||||
|
||||
// RegisterRequest represents the initial registration form data
|
||||
type RegisterRequest struct {
|
||||
ErrorMsg string `form:"error_msg" json:"error_msg"`
|
||||
Email string `json:"email" form:"email"`
|
||||
Password string `json:"password" form:"password"`
|
||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password"`
|
||||
FirstName string `json:"first_name" form:"first_name"`
|
||||
LastName string `json:"last_name" form:"last_name"`
|
||||
Lang string `form:"lang" json:"lang"`
|
||||
}
|
||||
|
||||
// CompleteRegistrationRequest represents the completion of registration with email verification
|
||||
type CompleteRegistrationRequest struct {
|
||||
Token string `json:"token" form:"token"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest represents the reset password form data
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token" form:"token"`
|
||||
Password string `json:"password" form:"password"`
|
||||
}
|
||||
|
||||
// AuthResponse represents the authentication response
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User *UserSession `json:"user"`
|
||||
}
|
||||
|
||||
// RefreshToken represents an opaque refresh token stored in the database
|
||||
type RefreshToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
CustomerID uint `gorm:"not null;index" json:"-"`
|
||||
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"` // SHA-256 hex of the raw token
|
||||
ExpiresAt time.Time `gorm:"not null" json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for RefreshToken model
|
||||
func (RefreshToken) TableName() string {
|
||||
return "refresh_tokens"
|
||||
}
|
||||
22
app/model/data.go
Normal file
22
app/model/data.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
// LoginRequest represents the login form data
|
||||
type DataRequest struct {
|
||||
RepoID uint `json:"repoid" form:"repoid"`
|
||||
Step uint `json:"step" form:"step"`
|
||||
}
|
||||
|
||||
type PageMeta struct {
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
type QuarterData struct {
|
||||
Time float64 `json:"time"`
|
||||
Quarter string `json:"quarter"`
|
||||
}
|
||||
|
||||
type DayData struct {
|
||||
Date string `json:"date"`
|
||||
Time float64 `json:"time"`
|
||||
}
|
||||
67
app/model/i18n.go
Normal file
67
app/model/i18n.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// contextKey is a custom type for context keys
|
||||
type contextKey string
|
||||
|
||||
// ContextLanguageID is the key for storing language ID in context
|
||||
const ContextLanguageID contextKey = "languageID"
|
||||
|
||||
type Translation struct {
|
||||
LangID uint `gorm:"primaryKey;column:lang_id"`
|
||||
ScopeID uint `gorm:"primaryKey;column:scope_id"`
|
||||
ComponentID uint `gorm:"primaryKey;column:component_id"`
|
||||
Key string `gorm:"primaryKey;size:255;column:key"`
|
||||
Data *string `gorm:"type:text;column:data"`
|
||||
|
||||
Language Language `gorm:"foreignKey:LangID;references:ID;constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT"`
|
||||
Scope Scope `gorm:"foreignKey:ScopeID;references:ID;constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT"`
|
||||
Component Component `gorm:"foreignKey:ComponentID;references:ID;constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT"`
|
||||
}
|
||||
|
||||
func (Translation) TableName() string {
|
||||
return "translations"
|
||||
}
|
||||
|
||||
type Language struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement;column:id"`
|
||||
CreatedAt time.Time `gorm:"not null;column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_language_deleted_at;column:deleted_at"`
|
||||
Name string `gorm:"size:128;not null;column:name"`
|
||||
ISOCode string `gorm:"size:2;not null;column:iso_code"`
|
||||
LangCode string `gorm:"size:5;not null;column:lang_code"`
|
||||
DateFormat string `gorm:"size:32;not null;column:date_format"`
|
||||
DateFormatShort string `gorm:"size:32;not null;column:date_format_short"`
|
||||
RTL bool `gorm:"not null;default:0;column:rtl"`
|
||||
IsDefault bool `gorm:"not null;default:0;column:is_default;comment:there should be only one default language"`
|
||||
Active bool `gorm:"not null;default:1;column:active"`
|
||||
Flag string `gorm:"size:16;not null;column:flag"`
|
||||
}
|
||||
|
||||
func (Language) TableName() string {
|
||||
return "language"
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement;column:id"`
|
||||
Name string `gorm:"size:255;not null;uniqueIndex:uk_components_name;column:name"`
|
||||
}
|
||||
|
||||
func (Component) TableName() string {
|
||||
return "components"
|
||||
}
|
||||
|
||||
type Scope struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement;column:id"`
|
||||
Name string `gorm:"size:255;not null;uniqueIndex:uk_scopes_name;column:name"`
|
||||
}
|
||||
|
||||
func (Scope) TableName() string {
|
||||
return "scopes"
|
||||
}
|
||||
61
app/model/repository.go
Normal file
61
app/model/repository.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Repository struct {
|
||||
ID int64 `db:"id"`
|
||||
OwnerID *int64 `db:"owner_id"`
|
||||
OwnerName *string `db:"owner_name"`
|
||||
LowerName string `db:"lower_name"`
|
||||
Name string `db:"name"`
|
||||
Description *string `db:"description"`
|
||||
Website *string `db:"website"`
|
||||
OriginalServiceType *int `db:"original_service_type"`
|
||||
OriginalURL *string `db:"original_url"`
|
||||
DefaultBranch *string `db:"default_branch"`
|
||||
DefaultWikiBranch *string `db:"default_wiki_branch"`
|
||||
|
||||
NumWatches *int `db:"num_watches"`
|
||||
NumStars *int `db:"num_stars"`
|
||||
NumForks *int `db:"num_forks"`
|
||||
NumIssues *int `db:"num_issues"`
|
||||
NumClosedIssues *int `db:"num_closed_issues"`
|
||||
NumPulls *int `db:"num_pulls"`
|
||||
NumClosedPulls *int `db:"num_closed_pulls"`
|
||||
|
||||
NumMilestones int `db:"num_milestones"`
|
||||
NumClosedMilestones int `db:"num_closed_milestones"`
|
||||
NumProjects int `db:"num_projects"`
|
||||
NumClosedProjects int `db:"num_closed_projects"`
|
||||
NumActionRuns int `db:"num_action_runs"`
|
||||
NumClosedActionRuns int `db:"num_closed_action_runs"`
|
||||
|
||||
IsPrivate *bool `db:"is_private"`
|
||||
IsEmpty *bool `db:"is_empty"`
|
||||
IsArchived *bool `db:"is_archived"`
|
||||
IsMirror *bool `db:"is_mirror"`
|
||||
|
||||
Status int `db:"status"`
|
||||
IsFork bool `db:"is_fork"`
|
||||
ForkID *int64 `db:"fork_id"`
|
||||
|
||||
IsTemplate bool `db:"is_template"`
|
||||
TemplateID *int64 `db:"template_id"`
|
||||
|
||||
Size int64 `db:"size"`
|
||||
GitSize int64 `db:"git_size"`
|
||||
LFSSize int64 `db:"lfs_size"`
|
||||
|
||||
IsFsckEnabled bool `db:"is_fsck_enabled"`
|
||||
CloseIssuesViaCommitAnyBranch bool `db:"close_issues_via_commit_in_any_branch"`
|
||||
|
||||
Topics json.RawMessage `db:"topics"`
|
||||
|
||||
ObjectFormatName string `db:"object_format_name"`
|
||||
TrustModel *int `db:"trust_model"`
|
||||
Avatar *string `db:"avatar"`
|
||||
|
||||
CreatedUnix *int64 `db:"created_unix"`
|
||||
UpdatedUnix *int64 `db:"updated_unix"`
|
||||
ArchivedUnix int64 `db:"archived_unix"`
|
||||
}
|
||||
509
app/service/authService/auth.go
Normal file
509
app/service/authService/auth.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package authService
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/db"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/emailService"
|
||||
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// JWTClaims represents the JWT claims
|
||||
type JWTClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role model.CustomerRole `json:"customer_role"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
config *config.AuthConfig
|
||||
email *emailService.EmailService
|
||||
}
|
||||
|
||||
// NewAuthService creates a new AuthService instance
|
||||
func NewAuthService() *AuthService {
|
||||
svc := &AuthService{
|
||||
db: db.Get(),
|
||||
config: &config.Get().Auth,
|
||||
email: emailService.NewEmailService(),
|
||||
}
|
||||
// Auto-migrate the refresh_tokens table
|
||||
if svc.db != nil {
|
||||
_ = svc.db.AutoMigrate(&model.RefreshToken{})
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// Login authenticates a user with email and password
|
||||
func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, string, error) {
|
||||
var user model.Customer
|
||||
|
||||
// Find user by email
|
||||
if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, "", view.ErrInvalidCredentials
|
||||
}
|
||||
return nil, "", fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return nil, "", view.ErrUserInactive
|
||||
}
|
||||
|
||||
// Check if email is verified
|
||||
if !user.EmailVerified {
|
||||
return nil, "", view.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
return nil, "", view.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
s.db.Save(&user)
|
||||
|
||||
// Generate access token (JWT)
|
||||
accessToken, err := s.generateAccessToken(&user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate opaque refresh token and store in DB
|
||||
rawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, rawRefreshToken, nil
|
||||
}
|
||||
|
||||
// Register initiates user registration
|
||||
func (s *AuthService) Register(req *model.RegisterRequest) error {
|
||||
// Check if email already exists
|
||||
var existingUser model.Customer
|
||||
if err := s.db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
|
||||
return view.ErrEmailExists
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if req.Password != req.ConfirmPassword {
|
||||
return view.ErrPasswordsDoNotMatch
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if err := validatePassword(req.Password); err != nil {
|
||||
return view.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
token, err := s.generateVerificationToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate verification token: %w", err)
|
||||
}
|
||||
|
||||
// Set expiration (24 hours from now)
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
|
||||
// Create user with verification token
|
||||
user := model.Customer{
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Role: model.RoleUser,
|
||||
Provider: model.ProviderLocal,
|
||||
IsActive: false,
|
||||
EmailVerified: false,
|
||||
EmailVerificationToken: token,
|
||||
EmailVerificationExpires: &expiresAt,
|
||||
Lang: req.Lang,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Send verification email
|
||||
baseURL := config.Get().App.BaseURL
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = "en" // Default to English
|
||||
}
|
||||
if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil {
|
||||
// Log error but don't fail registration - user can request resend
|
||||
_ = err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteRegistration completes the registration with password verification after email verification
|
||||
func (s *AuthService) CompleteRegistration(req *model.CompleteRegistrationRequest) (*model.AuthResponse, string, error) {
|
||||
// Find user by verification token
|
||||
var user model.Customer
|
||||
if err := s.db.Where("email_verification_token = ?", req.Token).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, "", view.ErrInvalidVerificationToken
|
||||
}
|
||||
return nil, "", fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if user.EmailVerificationExpires != nil && user.EmailVerificationExpires.Before(time.Now()) {
|
||||
return nil, "", view.ErrVerificationTokenExpired
|
||||
}
|
||||
|
||||
// Update user - activate account and mark email as verified
|
||||
user.IsActive = true
|
||||
user.EmailVerified = true
|
||||
user.EmailVerificationToken = ""
|
||||
user.EmailVerificationExpires = nil
|
||||
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return nil, "", fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
// Send admin notification about new user registration
|
||||
baseURL := config.Get().App.BaseURL
|
||||
if err := s.email.SendNewUserAdminNotification(user.Email, user.FullName(), baseURL); err != nil {
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
accessToken, err := s.generateAccessToken(&user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate opaque refresh token and store in DB
|
||||
rawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, rawRefreshToken, nil
|
||||
}
|
||||
|
||||
// RequestPasswordReset initiates a password reset request
|
||||
func (s *AuthService) RequestPasswordReset(emailAddr string) error {
|
||||
// Find user by email
|
||||
var user model.Customer
|
||||
if err := s.db.Where("email = ?", emailAddr).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Don't reveal if email exists or not for security
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check if user is active, email verified, and is a local user
|
||||
if !user.IsActive || !user.EmailVerified || user.Provider != model.ProviderLocal {
|
||||
// Don't reveal account status for security
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check rate limit: don't allow password reset requests more than once per hour
|
||||
if user.LastPasswordResetRequest != nil && time.Since(*user.LastPasswordResetRequest) < time.Hour {
|
||||
// Rate limit hit, silently fail for security
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
token, err := s.generateVerificationToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate reset token: %w", err)
|
||||
}
|
||||
|
||||
// Set expiration (1 hour from now)
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
// Update user with reset token and last request time
|
||||
now := time.Now()
|
||||
user.PasswordResetToken = token
|
||||
user.PasswordResetExpires = &expiresAt
|
||||
user.LastPasswordResetRequest = &now
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to save reset token: %w", err)
|
||||
}
|
||||
|
||||
// Send password reset email
|
||||
baseURL := config.Get().App.BaseURL
|
||||
lang := "en"
|
||||
if user.Lang != "" {
|
||||
lang = user.Lang
|
||||
}
|
||||
if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil {
|
||||
_ = err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword completes the password reset with a new password
|
||||
func (s *AuthService) ResetPassword(token, newPassword string) error {
|
||||
// Find user by reset token
|
||||
var user model.Customer
|
||||
if err := s.db.Where("password_reset_token = ?", token).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return view.ErrInvalidResetToken
|
||||
}
|
||||
return fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if user.PasswordResetExpires == nil || user.PasswordResetExpires.Before(time.Now()) {
|
||||
return view.ErrResetTokenExpired
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return view.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Update user password and clear reset token
|
||||
user.Password = string(hashedPassword)
|
||||
user.PasswordResetToken = ""
|
||||
user.PasswordResetExpires = nil
|
||||
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Revoke all existing refresh tokens for this user (security: password changed)
|
||||
s.db.Where("customer_id = ?", user.ID).Delete(&model.RefreshToken{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT access token and returns the claims
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, view.ErrTokenExpired
|
||||
}
|
||||
return nil, view.ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*JWTClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, view.ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// RefreshToken validates an opaque refresh token, rotates it, and returns a new access token.
|
||||
// Returns: AuthResponse, new raw refresh token, error
|
||||
func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string, error) {
|
||||
tokenHash := hashToken(rawToken)
|
||||
|
||||
// Find the refresh token record
|
||||
var rt model.RefreshToken
|
||||
if err := s.db.Where("token_hash = ?", tokenHash).First(&rt).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, "", view.ErrInvalidToken
|
||||
}
|
||||
return nil, "", fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if rt.ExpiresAt.Before(time.Now()) {
|
||||
// Clean up expired token
|
||||
s.db.Delete(&rt)
|
||||
return nil, "", view.ErrTokenExpired
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user model.Customer
|
||||
if err := s.db.First(&user, rt.CustomerID).Error; err != nil {
|
||||
return nil, "", view.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, "", view.ErrUserInactive
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return nil, "", view.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
// Delete the old refresh token (rotation: one-time use)
|
||||
s.db.Delete(&rt)
|
||||
|
||||
// Generate new access token
|
||||
accessToken, err := s.generateAccessToken(&user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Issue a new opaque refresh token
|
||||
newRawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, newRawRefreshToken, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken deletes a specific refresh token from the DB (used on logout)
|
||||
func (s *AuthService) RevokeRefreshToken(rawToken string) {
|
||||
if rawToken == "" {
|
||||
return
|
||||
}
|
||||
tokenHash := hashToken(rawToken)
|
||||
s.db.Where("token_hash = ?", tokenHash).Delete(&model.RefreshToken{})
|
||||
}
|
||||
|
||||
// RevokeAllRefreshTokens deletes all refresh tokens for a user (used on logout-all-devices)
|
||||
func (s *AuthService) RevokeAllRefreshTokens(userID uint) {
|
||||
s.db.Where("customer_id = ?", userID).Delete(&model.RefreshToken{})
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) {
|
||||
var user model.Customer
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, view.ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
func (s *AuthService) GetUserByEmail(email string) (*model.Customer, error) {
|
||||
var user model.Customer
|
||||
if err := s.db.Where("email = ?", email).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, view.ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// createRefreshToken generates a random opaque token, stores its hash in the DB, and returns the raw token.
|
||||
func (s *AuthService) createRefreshToken(userID uint) (string, error) {
|
||||
// Generate 32 random bytes → 64-char hex string
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rawToken := hex.EncodeToString(b)
|
||||
tokenHash := hashToken(rawToken)
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(s.config.RefreshExpiration) * time.Second)
|
||||
|
||||
rt := model.RefreshToken{
|
||||
CustomerID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := s.db.Create(&rt).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return rawToken, nil
|
||||
}
|
||||
|
||||
// hashToken returns the SHA-256 hex digest of a raw token string.
|
||||
func hashToken(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// generateAccessToken generates a short-lived JWT access token
|
||||
func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) {
|
||||
claims := JWTClaims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Email,
|
||||
Role: user.Role,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.config.JWTSecret))
|
||||
}
|
||||
|
||||
// generateVerificationToken generates a random verification token
|
||||
func (s *AuthService) generateVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// validatePassword validates password strength using RE2-compatible regexes.
|
||||
func validatePassword(password string) error {
|
||||
var passregex2 = regexp2.MustCompile(constdata.PASSWORD_VALIDATION_REGEX, regexp2.None)
|
||||
|
||||
if ok, _ := passregex2.MatchString(password); !ok {
|
||||
return errors.New("password must be at least 10 characters long and contain at least one lowercase letter, one uppercase letter, and one digit")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
204
app/service/authService/google_oauth.go
Normal file
204
app/service/authService/google_oauth.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package authService
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
const googleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
|
||||
// GoogleUserInfo represents the user info returned by Google
|
||||
type GoogleUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
VerifiedEmail bool `json:"verified_email"`
|
||||
Name string `json:"name"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// googleOAuthConfig returns the OAuth2 config for Google
|
||||
func googleOAuthConfig() *oauth2.Config {
|
||||
cfg := config.Get().OAuth.Google
|
||||
scopes := cfg.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
}
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: scopes,
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateOAuthState generates a random state token for CSRF protection
|
||||
func (s *AuthService) GenerateOAuthState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GetGoogleAuthURL returns the Google OAuth2 authorization URL with a state token
|
||||
func (s *AuthService) GetGoogleAuthURL(state string) string {
|
||||
return googleOAuthConfig().AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
}
|
||||
|
||||
// HandleGoogleCallback exchanges the code for a token, fetches user info,
|
||||
// and either logs in or registers the user, returning an AuthResponse and raw refresh token.
|
||||
func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, string, error) {
|
||||
oauthCfg := googleOAuthConfig()
|
||||
|
||||
// Exchange authorization code for token
|
||||
token, err := oauthCfg.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
// Fetch user info from Google
|
||||
userInfo, err := fetchGoogleUserInfo(oauthCfg.Client(context.Background(), token))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to fetch user info: %w", err)
|
||||
}
|
||||
|
||||
if !userInfo.VerifiedEmail {
|
||||
return nil, "", view.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
user, err := s.findOrCreateGoogleUser(userInfo)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Update last login
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
s.db.Save(user)
|
||||
|
||||
// Generate access token (JWT)
|
||||
accessToken, err := s.generateAccessToken(user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate opaque refresh token and store in DB
|
||||
rawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, rawRefreshToken, nil
|
||||
}
|
||||
|
||||
// findOrCreateGoogleUser finds an existing user by Google provider ID or email,
|
||||
// or creates a new one.
|
||||
func (s *AuthService) findOrCreateGoogleUser(info *GoogleUserInfo) (*model.Customer, error) {
|
||||
var user model.Customer
|
||||
|
||||
// Try to find by provider + provider_id
|
||||
err := s.db.Where("provider = ? AND provider_id = ?", model.ProviderGoogle, info.ID).First(&user).Error
|
||||
if err == nil {
|
||||
// Update avatar in case it changed
|
||||
user.AvatarURL = info.Picture
|
||||
s.db.Save(&user)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Try to find by email (user may have registered locally before)
|
||||
err = s.db.Where("email = ?", info.Email).First(&user).Error
|
||||
if err == nil {
|
||||
// Link Google provider to existing account
|
||||
user.Provider = model.ProviderGoogle
|
||||
user.ProviderID = info.ID
|
||||
user.AvatarURL = info.Picture
|
||||
user.IsActive = true
|
||||
s.db.Save(&user)
|
||||
|
||||
// If email has not been verified yet, send email to admin.
|
||||
if !user.EmailVerified {
|
||||
baseURL := config.Get().App.BaseURL
|
||||
if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil {
|
||||
// Log error but don't fail registration
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
user.EmailVerified = true
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Create new user
|
||||
newUser := model.Customer{
|
||||
Email: info.Email,
|
||||
FirstName: info.GivenName,
|
||||
LastName: info.FamilyName,
|
||||
Provider: model.ProviderGoogle,
|
||||
ProviderID: info.ID,
|
||||
AvatarURL: info.Picture,
|
||||
Role: model.RoleUser,
|
||||
IsActive: true,
|
||||
EmailVerified: true,
|
||||
Lang: "en",
|
||||
}
|
||||
|
||||
if err := s.db.Create(&newUser).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// If everything succeeded, send email to admin.
|
||||
if !user.EmailVerified {
|
||||
baseURL := config.Get().App.BaseURL
|
||||
if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil {
|
||||
// Log error but don't fail registration
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
return &newUser, nil
|
||||
}
|
||||
|
||||
// fetchGoogleUserInfo fetches user info from Google using the provided HTTP client
|
||||
func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) {
|
||||
resp, err := client.Get(googleUserInfoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userInfo GoogleUserInfo
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &userInfo, nil
|
||||
}
|
||||
138
app/service/emailService/email.go
Normal file
138
app/service/emailService/email.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package emailService
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/emails"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
// EmailService handles sending emails
|
||||
type EmailService struct {
|
||||
config *config.EmailConfig
|
||||
}
|
||||
|
||||
// NewEmailService creates a new EmailService instance
|
||||
func NewEmailService() *EmailService {
|
||||
return &EmailService{
|
||||
config: &config.Get().Email,
|
||||
}
|
||||
}
|
||||
|
||||
// getLangID returns the language ID from the ISO code using the language service
|
||||
func getLangID(isoCode string) uint {
|
||||
if isoCode == "" {
|
||||
isoCode = "en"
|
||||
}
|
||||
|
||||
lang, err := langs.LangSrv.GetLanguageByISOCode(isoCode)
|
||||
if err != nil || lang == nil {
|
||||
return 1 // Default to English (ID 1)
|
||||
}
|
||||
|
||||
return uint(lang.ID)
|
||||
}
|
||||
|
||||
// SendEmail sends an email to the specified recipient
|
||||
func (s *EmailService) SendEmail(to, subject, body string) error {
|
||||
if !s.config.Enabled {
|
||||
return fmt.Errorf("email service is disabled")
|
||||
}
|
||||
|
||||
// Set up authentication
|
||||
auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPassword, s.config.SMTPHost)
|
||||
|
||||
// Create email headers
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = fmt.Sprintf("%s <%s>", s.config.FromName, s.config.FromEmail)
|
||||
headers["To"] = to
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=utf-8"
|
||||
|
||||
// Build email message
|
||||
var msg strings.Builder
|
||||
for k, v := range headers {
|
||||
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
// Send email
|
||||
addr := fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort)
|
||||
if err := smtp.SendMail(addr, auth, s.config.FromEmail, []string{to}, []byte(msg.String())); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendVerificationEmail sends an email verification email
|
||||
func (s *EmailService) SendVerificationEmail(to, token, baseURL, lang string) error {
|
||||
// Use default language if not provided
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
verificationURL := fmt.Sprintf("%s/%s/verify-email?token=%s", baseURL, lang, token)
|
||||
|
||||
langID := getLangID(lang)
|
||||
subject := i18n.T___(langID, "email.email_verification_subject")
|
||||
body := s.verificationEmailTemplate(to, verificationURL, langID)
|
||||
|
||||
return s.SendEmail(to, subject, body)
|
||||
}
|
||||
|
||||
// SendPasswordResetEmail sends a password reset email
|
||||
func (s *EmailService) SendPasswordResetEmail(to, token, baseURL, lang string) error {
|
||||
// Use default language if not provided
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
resetURL := fmt.Sprintf("%s/%s/reset-password?token=%s", baseURL, lang, token)
|
||||
|
||||
langID := getLangID(lang)
|
||||
subject := i18n.T___(langID, "email.email_password_reset_subject")
|
||||
body := s.passwordResetEmailTemplate(to, resetURL, langID)
|
||||
|
||||
return s.SendEmail(to, subject, body)
|
||||
}
|
||||
|
||||
// SendNewUserAdminNotification sends an email to admin when a new user completes registration
|
||||
func (s *EmailService) SendNewUserAdminNotification(userEmail, userName, baseURL string) error {
|
||||
if s.config.AdminEmail == "" {
|
||||
return nil // No admin email configured
|
||||
}
|
||||
|
||||
subject := "New User Registration - Repository Assignment Required"
|
||||
body := s.newUserAdminNotificationTemplate(userEmail, userName, baseURL)
|
||||
|
||||
return s.SendEmail(s.config.AdminEmail, subject, body)
|
||||
}
|
||||
|
||||
// verificationEmailTemplate returns the HTML template for email verification
|
||||
func (s *EmailService) verificationEmailTemplate(name, verificationURL string, langID uint) string {
|
||||
buf := bytes.Buffer{}
|
||||
emails.EmailVerificationWrapper(view.EmailLayout[view.EmailVerificationData]{LangID: langID, Data: view.EmailVerificationData{VerificationURL: verificationURL}}).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// passwordResetEmailTemplate returns the HTML template for password reset
|
||||
func (s *EmailService) passwordResetEmailTemplate(name, resetURL string, langID uint) string {
|
||||
buf := bytes.Buffer{}
|
||||
emails.EmailPasswordResetWrapper(view.EmailLayout[view.EmailPasswordResetData]{LangID: langID, Data: view.EmailPasswordResetData{ResetURL: resetURL}}).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// newUserAdminNotificationTemplate returns the HTML template for admin notification
|
||||
func (s *EmailService) newUserAdminNotificationTemplate(userEmail, userName, baseURL string) string {
|
||||
buf := bytes.Buffer{}
|
||||
emails.EmailAdminNotificationWrapper(view.EmailLayout[view.EmailAdminNotificationData]{LangID: 2, Data: view.EmailAdminNotificationData{UserEmail: userEmail, UserName: userName, BaseURL: baseURL}}).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
97
app/service/langs/service.go
Normal file
97
app/service/langs/service.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package langs
|
||||
|
||||
import (
|
||||
langs_repo "git.ma-al.com/goc_marek/timetracker/app/langs"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/nullable"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/response"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type LangService struct {
|
||||
repo langs_repo.LangsRepo
|
||||
}
|
||||
|
||||
type LangServiceMessage i18n.I18nTranslation
|
||||
|
||||
const (
|
||||
Message_LangsLoaded LangServiceMessage = "langs_loaded"
|
||||
Message_LangsNotLoaded LangServiceMessage = "langs_not_loaded"
|
||||
Message_TranslationsOK LangServiceMessage = "translations_loaded"
|
||||
Message_TranslationsNOK LangServiceMessage = "translations_not_loaded"
|
||||
)
|
||||
|
||||
var LangSrv *LangService
|
||||
|
||||
func (s *LangService) GetActive(c fiber.Ctx) response.Response[[]view.Language] {
|
||||
res, err := s.repo.GetActive()
|
||||
if err != nil {
|
||||
return response.Make[[]view.Language](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, response.Message_NOK))
|
||||
}
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(res), nullable.GetNil(len(res)), i18n.T_(c, response.Message_OK))
|
||||
}
|
||||
|
||||
// LoadTranslations loads all translations from the database into the cache
|
||||
func (s *LangService) LoadTranslations() error {
|
||||
translations, err := s.repo.GetAllTranslations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return i18n.TransStore.LoadTranslations(translations)
|
||||
}
|
||||
|
||||
// ReloadTranslations reloads translations from the database into the cache
|
||||
func (s *LangService) ReloadTranslations() error {
|
||||
translations, err := s.repo.GetAllTranslations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return i18n.TransStore.ReloadTranslations(translations)
|
||||
}
|
||||
|
||||
// GetTranslations returns translations from the cache
|
||||
func (s *LangService) GetTranslations(c fiber.Ctx, langID uint, scope string, components []string) response.Response[*i18n.TranslationResponse] {
|
||||
translations, err := i18n.TransStore.GetTranslations(langID, scope, components)
|
||||
if err != nil {
|
||||
return response.Make[*i18n.TranslationResponse](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, Message_TranslationsNOK))
|
||||
}
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK))
|
||||
}
|
||||
|
||||
// GetAllTranslations returns all translations from the cache
|
||||
func (s *LangService) GetAllTranslationsResponse(c fiber.Ctx) response.Response[*i18n.TranslationResponse] {
|
||||
translations := i18n.TransStore.GetAllTranslations()
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK))
|
||||
}
|
||||
|
||||
// ReloadTranslationsResponse returns response after reloading translations
|
||||
func (s *LangService) ReloadTranslationsResponse(c fiber.Ctx) response.Response[map[string]string] {
|
||||
err := s.ReloadTranslations()
|
||||
if err != nil {
|
||||
return response.Make[map[string]string](c, fiber.StatusInternalServerError, nil, nil, i18n.T_(c, Message_LangsNotLoaded))
|
||||
}
|
||||
result := map[string]string{"status": "success"}
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(result), nil, i18n.T_(c, Message_LangsLoaded))
|
||||
}
|
||||
|
||||
// GetDefaultLanguage returns the default language
|
||||
func (s *LangService) GetDefaultLanguage() (*view.Language, error) {
|
||||
return s.repo.GetDefault()
|
||||
}
|
||||
|
||||
// GetLanguageByISOCode returns a language by its ISO code
|
||||
func (s *LangService) GetLanguageByISOCode(isoCode string) (*view.Language, error) {
|
||||
return s.repo.GetByISOCode(isoCode)
|
||||
}
|
||||
|
||||
// GetLanguageByISOCode returns a language by its ISO code
|
||||
func (s *LangService) GetLanguageById(id uint) (*view.Language, error) {
|
||||
return s.repo.GetById(id)
|
||||
}
|
||||
|
||||
func init() {
|
||||
LangSrv = &LangService{
|
||||
repo: *langs_repo.New(),
|
||||
}
|
||||
}
|
||||
335
app/service/repoService/repo.go
Normal file
335
app/service/repoService/repo.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package repoService
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/db"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// type
|
||||
type RepoService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New() *RepoService {
|
||||
return &RepoService{
|
||||
db: db.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RepoService) GetRepositoriesForUser(userID uint) ([]uint, error) {
|
||||
var repoIDs []uint
|
||||
|
||||
err := s.db.
|
||||
Table("customer_repo_accesses").
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("repo_id", &repoIDs).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
return repoIDs, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) UserHasAccessToRepo(userID uint, repoID uint) (bool, error) {
|
||||
var repositories []uint
|
||||
var err error
|
||||
|
||||
if repositories, err = s.GetRepositoriesForUser(userID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !slices.Contains(repositories, repoID) {
|
||||
return false, view.ErrInvalidRepoID
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Extract all repositories assigned to user with specific id
|
||||
func (s *RepoService) GetYearsForUser(userID uint, repoID uint) ([]uint, error) {
|
||||
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
years, err := s.GetYears(repoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
return years, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetYears(repo uint) ([]uint, error) {
|
||||
|
||||
var years []uint
|
||||
|
||||
query := `
|
||||
WITH bounds AS (
|
||||
SELECT
|
||||
MIN(to_timestamp(tt.created_unix)) AS min_ts,
|
||||
MAX(to_timestamp(tt.created_unix)) AS max_ts
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
WHERE i.repo_id = ?
|
||||
AND tt.deleted = false
|
||||
)
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM y.year_start)::int AS year
|
||||
FROM bounds
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
date_trunc('year', min_ts),
|
||||
date_trunc('year', max_ts),
|
||||
interval '1 year'
|
||||
) AS y(year_start)
|
||||
ORDER BY year
|
||||
`
|
||||
|
||||
err := db.Get().Raw(query, repo).Find(&years).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
|
||||
// Extract all repositories assigned to user with specific id
|
||||
func (s *RepoService) GetQuartersForUser(userID uint, repoID uint, year uint) ([]model.QuarterData, error) {
|
||||
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := s.GetQuarters(repoID, year)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetQuarters(repo uint, year uint) ([]model.QuarterData, error) {
|
||||
var quarters []model.QuarterData
|
||||
|
||||
query := `
|
||||
WITH quarters AS (
|
||||
SELECT
|
||||
make_date(?::int, 1, 1) + (q * interval '3 months') AS quarter_start,
|
||||
q + 1 AS quarter
|
||||
FROM generate_series(0, 3) AS q
|
||||
),
|
||||
data AS (
|
||||
SELECT
|
||||
EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) AS quarter,
|
||||
SUM(tt.time) / 3600 AS time
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
JOIN repository r ON i.repo_id = r.id
|
||||
WHERE
|
||||
EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND r.id = ?
|
||||
AND tt.deleted = false
|
||||
GROUP BY EXTRACT(QUARTER FROM to_timestamp(tt.created_unix))
|
||||
)
|
||||
SELECT
|
||||
COALESCE(d.time, 0) AS time,
|
||||
CONCAT(EXTRACT(YEAR FROM q.quarter_start)::int, '_Q', q.quarter) AS quarter
|
||||
FROM quarters q
|
||||
LEFT JOIN data d ON d.quarter = q.quarter
|
||||
ORDER BY q.quarter
|
||||
`
|
||||
|
||||
err := db.Get().
|
||||
Raw(query, year, year, repo).
|
||||
Find(&quarters).
|
||||
Error
|
||||
if err != nil {
|
||||
fmt.Printf("err: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return quarters, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetTotalTimeForQuarter(repo uint, year uint, quarter uint) (float64, error) {
|
||||
var total float64
|
||||
|
||||
query := `
|
||||
SELECT COALESCE(SUM(tt.time) / 3600, 0) AS total_time
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
WHERE i.repo_id = ?
|
||||
AND EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND tt.deleted = false
|
||||
`
|
||||
|
||||
err := db.Get().Raw(query, repo, year, quarter).Row().Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetTimeTracked(repo uint, year uint, quarter uint, step string) ([]model.DayData, error) {
|
||||
var days []model.DayData
|
||||
|
||||
// Calculate quarter start and end dates
|
||||
quarterStartMonth := (quarter-1)*3 + 1
|
||||
quarterStart := fmt.Sprintf("%d-%02d-01", year, quarterStartMonth)
|
||||
var quarterEnd string
|
||||
switch quarter {
|
||||
case 1:
|
||||
quarterEnd = fmt.Sprintf("%d-03-31", year)
|
||||
case 2:
|
||||
quarterEnd = fmt.Sprintf("%d-06-30", year)
|
||||
case 3:
|
||||
quarterEnd = fmt.Sprintf("%d-09-30", year)
|
||||
default:
|
||||
quarterEnd = fmt.Sprintf("%d-12-31", year)
|
||||
}
|
||||
|
||||
var bucketExpr string
|
||||
var seriesInterval string
|
||||
var seriesStart string
|
||||
var seriesEnd string
|
||||
|
||||
switch step {
|
||||
case "day":
|
||||
bucketExpr = "DATE(to_timestamp(tt.created_unix))"
|
||||
seriesInterval = "1 day"
|
||||
seriesStart = "p.start_date"
|
||||
seriesEnd = "p.end_date"
|
||||
|
||||
case "week":
|
||||
bucketExpr = `
|
||||
(p.start_date +
|
||||
((DATE(to_timestamp(tt.created_unix)) - p.start_date) / 7) * 7
|
||||
)::date`
|
||||
seriesInterval = "7 days"
|
||||
seriesStart = "p.start_date"
|
||||
seriesEnd = "p.end_date"
|
||||
|
||||
case "month":
|
||||
bucketExpr = "date_trunc('month', to_timestamp(tt.created_unix))::date"
|
||||
seriesInterval = "1 month"
|
||||
seriesStart = "date_trunc('month', p.start_date)"
|
||||
seriesEnd = "date_trunc('month', p.end_date)"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH params AS (
|
||||
SELECT ?::date AS start_date, ?::date AS end_date
|
||||
),
|
||||
date_range AS (
|
||||
SELECT generate_series(
|
||||
%s,
|
||||
%s,
|
||||
interval '%s'
|
||||
)::date AS date
|
||||
FROM params p
|
||||
),
|
||||
data AS (
|
||||
SELECT
|
||||
%s AS date,
|
||||
SUM(tt.time) / 3600 AS time
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
CROSS JOIN params p
|
||||
WHERE i.repo_id = ?
|
||||
AND to_timestamp(tt.created_unix) >= p.start_date
|
||||
AND to_timestamp(tt.created_unix) < p.end_date + interval '1 day'
|
||||
AND tt.deleted = false
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
TO_CHAR(dr.date, 'YYYY-MM-DD') AS date,
|
||||
COALESCE(d.time, 0) AS time
|
||||
FROM date_range dr
|
||||
LEFT JOIN data d ON d.date = dr.date
|
||||
ORDER BY dr.date
|
||||
`, seriesStart, seriesEnd, seriesInterval, bucketExpr)
|
||||
err := db.Get().
|
||||
Raw(query, quarterStart, quarterEnd, repo).
|
||||
Scan(&days).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetRepoData(repoIds []uint) ([]model.Repository, error) {
|
||||
var repos []model.Repository
|
||||
|
||||
err := db.Get().Model(model.Repository{}).Where("id = ?", repoIds).Find(&repos).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetIssuesForUser(
|
||||
userID uint,
|
||||
repoID uint,
|
||||
year uint,
|
||||
quarter uint,
|
||||
p pagination.Paging,
|
||||
) (*pagination.Found[view.IssueTimeSummary], error) {
|
||||
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetIssues(repoID, year, quarter, p)
|
||||
}
|
||||
|
||||
func (s *RepoService) GetIssues(
|
||||
repoId uint,
|
||||
year uint,
|
||||
quarter uint,
|
||||
p pagination.Paging,
|
||||
) (*pagination.Found[view.IssueTimeSummary], error) {
|
||||
|
||||
query := db.Get().Debug().
|
||||
Table("issue i").
|
||||
Select(`
|
||||
i.id AS issue_id,
|
||||
i.name AS issue_name,
|
||||
u.id AS user_id,
|
||||
upper(
|
||||
regexp_replace(
|
||||
regexp_replace(u.full_name, '(\y\w)\w*', '\1', 'g'),
|
||||
'(\w)', '\1.', 'g'
|
||||
)
|
||||
) AS initials,
|
||||
to_timestamp(tt.created_unix)::date AS created_date,
|
||||
ROUND(SUM(tt.time) / 3600.0, 2) AS total_hours_spent
|
||||
`).
|
||||
Joins(`JOIN tracked_time tt ON tt.issue_id = i.id`).
|
||||
Joins(`JOIN "user" u ON u.id = tt.user_id`).
|
||||
Where("i.repo_id = ?", repoId).
|
||||
Where(`
|
||||
EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) = ?
|
||||
`, year, quarter).
|
||||
Group(`
|
||||
i.id,
|
||||
i.name,
|
||||
u.id,
|
||||
u.full_name,
|
||||
created_date
|
||||
`).
|
||||
Order("created_date")
|
||||
|
||||
result, err := pagination.Paginate[view.IssueTimeSummary](p, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
35
app/templ/emails/emailAdminNotification.templ
Normal file
35
app/templ/emails/emailAdminNotification.templ
Normal file
@@ -0,0 +1,35 @@
|
||||
package emails
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
)
|
||||
|
||||
templ EmailAdminNotificationWrapper(data view.EmailLayout[view.EmailAdminNotificationData]) {
|
||||
@layout.Base( i18n.T___(data.LangID, "email.email_admin_notification_title")) {
|
||||
<div class="container">
|
||||
<div class="email-wrapper">
|
||||
<div class="email-header">
|
||||
<h1>New User Registration</h1>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p>Hello Administrator,</p>
|
||||
<p>A new user has completed their registration and requires repository access.</p>
|
||||
<div class="info-box">
|
||||
<strong>User Details:</strong>
|
||||
<p><strong>Name:</strong> { data.Data.UserName } </p>
|
||||
<p><strong>Email:</strong> { data.Data.UserEmail } </p>
|
||||
</div>
|
||||
<p>Please assign the appropriate repositories to this user in the admin panel.</p>
|
||||
<div style="text-align: center;">
|
||||
<a href="{ data.Data.BaseURL }/admin/users" class="button">Go to Admin Panel</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>© 2024 Gitea Manager. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
90
app/templ/emails/emailAdminNotification_templ.go
Normal file
90
app/templ/emails/emailAdminNotification_templ.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package emails
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
func EmailAdminNotificationWrapper(data view.EmailLayout[view.EmailAdminNotificationData]) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container\"><div class=\"email-wrapper\"><div class=\"email-header\"><h1>New User Registration</h1></div><div class=\"email-body\"><p>Hello Administrator,</p><p>A new user has completed their registration and requires repository access.</p><div class=\"info-box\"><strong>User Details:</strong><p><strong>Name:</strong> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.UserName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailAdminNotification.templ`, Line: 21, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</p><p><strong>Email:</strong> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.UserEmail)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailAdminNotification.templ`, Line: 22, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p></div><p>Please assign the appropriate repositories to this user in the admin panel.</p><div style=\"text-align: center;\"><a href=\"{ data.Data.BaseURL }/admin/users\" class=\"button\">Go to Admin Panel</a></div></div><div class=\"email-footer\"><p>© 2024 Gitea Manager. All rights reserved.</p></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layout.Base(i18n.T___(data.LangID, "email.email_admin_notification_title")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
34
app/templ/emails/emailPasswordReset.templ
Normal file
34
app/templ/emails/emailPasswordReset.templ
Normal file
@@ -0,0 +1,34 @@
|
||||
package emails
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
)
|
||||
|
||||
templ EmailPasswordResetWrapper(data view.EmailLayout[view.EmailPasswordResetData]) {
|
||||
@layout.Base( i18n.T___(data.LangID, "email.email_password_reset_title")) {
|
||||
<div class="container">
|
||||
<div class="email-wrapper">
|
||||
<div class="email-header">
|
||||
<h1> { i18n.T___(data.LangID, "email.email_greeting") } </h1>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p> { i18n.T___(data.LangID, "email.email_password_reset_message1") } </p>
|
||||
<div style="text-align: center;">
|
||||
<a href= { data.Data.ResetURL } class="button"> { i18n.T___(data.LangID, "email.email_reset_button") } </a>
|
||||
</div>
|
||||
<p> { i18n.T___(data.LangID, "email.email_or_copy") }</p>
|
||||
<div class="link-container"> { data.Data.ResetURL } </div>
|
||||
<div class="warning">
|
||||
<strong> { i18n.T___(data.LangID, "email.email_warning_title") } </strong> { i18n.T___(data.LangID, "email.email_password_reset_warning") }
|
||||
</div>
|
||||
<p> { i18n.T___(data.LangID, "email.email_ignore_reset") } </p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>© 2024 Gitea Manager. { i18n.T___(data.LangID, "email.email_footer") } </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
194
app/templ/emails/emailPasswordReset_templ.go
Normal file
194
app/templ/emails/emailPasswordReset_templ.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package emails
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
func EmailPasswordResetWrapper(data view.EmailLayout[view.EmailPasswordResetData]) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container\"><div class=\"email-wrapper\"><div class=\"email-header\"><h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_greeting"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 14, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1></div><div class=\"email-body\"><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_password_reset_message1"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 17, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p><div style=\"text-align: center;\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(data.Data.ResetURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 19, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"button\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_reset_button"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 19, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a></div><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_or_copy"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 21, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p><div class=\"link-container\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.ResetURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 22, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"warning\"><strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_warning_title"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 24, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</strong> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_password_reset_warning"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 24, Col: 161}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_ignore_reset"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 26, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div><div class=\"email-footer\"><p>© 2024 Gitea Manager. ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_footer"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 29, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layout.Base(i18n.T___(data.LangID, "email.email_password_reset_title")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
34
app/templ/emails/emailVerification.templ
Normal file
34
app/templ/emails/emailVerification.templ
Normal file
@@ -0,0 +1,34 @@
|
||||
package emails
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
)
|
||||
|
||||
templ EmailVerificationWrapper(data view.EmailLayout[view.EmailVerificationData]) {
|
||||
@layout.Base( i18n.T___(data.LangID, "email.email_verification_title")) {
|
||||
<div class="container">
|
||||
<div class="email-wrapper">
|
||||
<div class="email-header">
|
||||
<h1>{ i18n.T___(data.LangID, "email.email_verification_title") }</h1>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p>{ i18n.T___(data.LangID, "email.email_greeting") }</p>
|
||||
<p>{ i18n.T___(data.LangID, "email.email_verification_message1") }</p>
|
||||
<div style="text-align: center;">
|
||||
<a href={ data.Data.VerificationURL } class="button">{ i18n.T___(data.LangID, "email.email_verify_button") }</a>
|
||||
</div>
|
||||
<p>{ i18n.T___(data.LangID, "email.email_or_copy") }</p>
|
||||
<div class="link-container">{ data.Data.VerificationURL }</div>
|
||||
<p><strong>{ i18n.T___(data.LangID, "email.email_verification_note") }</strong></p>
|
||||
<p>{ i18n.T___(data.LangID, "email.email_ignore") }</p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>© 2024 Gitea Manager. { i18n.T___(data.LangID, "email.email_footer")}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
194
app/templ/emails/emailVerification_templ.go
Normal file
194
app/templ/emails/emailVerification_templ.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package emails
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
func EmailVerificationWrapper(data view.EmailLayout[view.EmailVerificationData]) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container\"><div class=\"email-wrapper\"><div class=\"email-header\"><h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verification_title"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 14, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1></div><div class=\"email-body\"><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_greeting"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 17, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verification_message1"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 18, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</p><div style=\"text-align: center;\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(data.Data.VerificationURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 20, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"button\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verify_button"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 20, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a></div><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_or_copy"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 22, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p><div class=\"link-container\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.VerificationURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 23, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><p><strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verification_note"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 24, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</strong></p><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_ignore"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 25, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div><div class=\"email-footer\"><p>© 2024 Gitea Manager. ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_footer"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 28, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layout.Base(i18n.T___(data.LangID, "email.email_verification_title")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
83
app/templ/layout/base.templ
Normal file
83
app/templ/layout/base.templ
Normal file
@@ -0,0 +1,83 @@
|
||||
package layout
|
||||
|
||||
templ Base(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@head(title)
|
||||
<body style="margin:0;padding:0;background:#f4f4f4;">
|
||||
{ children... }
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ head(title string) {
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ title }</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.email-wrapper {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #dddddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #4A90E2;
|
||||
color: #ffffff;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.email-body {
|
||||
padding: 30px;
|
||||
}
|
||||
.email-body p {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #4A90E2;
|
||||
color: #ffffff;
|
||||
padding: 14px 28px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f8f8f8;
|
||||
padding: 20px 30px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
.link-container {
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
background-color: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
}
|
||||
98
app/templ/layout/base_templ.go
Normal file
98
app/templ/layout/base_templ.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package layout
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func Base(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = head(title).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body style=\"margin:0;padding:0;background:#f4f4f4;\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func head(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/layout/base.templ`, Line: 17, Col: 16}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</title><style>\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;\n line-height: 1.6;\n color: #333333;\n margin: 0;\n padding: 0;\n background-color: #f4f4f4;\n }\n .container {\n max-width: 600px;\n margin: 0 auto;\n padding: 20px;\n }\n .email-wrapper {\n background-color: #ffffff;\n border-radius: 8px;\n border: 2px solid #dddddd;\n overflow: hidden;\n }\n .email-header {\n background-color: #4A90E2;\n color: #ffffff;\n padding: 30px;\n text-align: center;\n }\n .email-header h1 {\n margin: 0;\n font-size: 24px;\n font-weight: 600;\n }\n .email-body {\n padding: 30px;\n }\n .email-body p {\n margin: 0 0 16px 0;\n }\n .button {\n display: inline-block;\n background-color: #4A90E2;\n color: #ffffff;\n padding: 14px 28px;\n text-decoration: none;\n border-radius: 6px;\n font-weight: 500;\n margin: 20px 0;\n }\n .email-footer {\n background-color: #f8f8f8;\n padding: 20px 30px;\n text-align: center;\n font-size: 12px;\n color: #666666;\n }\n .link-container {\n word-break: break-all;\n font-size: 12px;\n color: #666666;\n background-color: #f8f8f8;\n padding: 10px;\n border-radius: 4px;\n margin: 10px 0;\n }\n </style></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
4
app/utils/const_data/consts.go
Normal file
4
app/utils/const_data/consts.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package constdata
|
||||
|
||||
// PASSWORD_VALIDATION_REGEX is used by the frontend (JavaScript supports lookaheads).
|
||||
const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$`
|
||||
237
app/utils/i18n/i18n.go
Normal file
237
app/utils/i18n/i18n.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type I18nTranslation string
|
||||
|
||||
type TranslationResponse map[uint]map[string]map[string]map[string]string
|
||||
|
||||
var TransStore = newTranslationsStore()
|
||||
|
||||
var (
|
||||
ErrLangIsoEmpty = errors.New("lang_id_empty")
|
||||
ErrScopeEmpty = errors.New("scope_empty")
|
||||
ErrComponentEmpty = errors.New("component_empty")
|
||||
ErrKeyEmpty = errors.New("key_empty")
|
||||
|
||||
ErrLangIsoNotFoundInCache = errors.New("lang_id_not_in_cache")
|
||||
ErrScopeNotFoundInCache = errors.New("scope_not_in_cache")
|
||||
ErrComponentNotFoundInCache = errors.New("component_not_in_cache")
|
||||
ErrKeyNotFoundInCache = errors.New("key_invalid_in_cache")
|
||||
)
|
||||
|
||||
type TranslationsStore struct {
|
||||
mutex sync.RWMutex
|
||||
cache TranslationResponse
|
||||
}
|
||||
|
||||
func newTranslationsStore() *TranslationsStore {
|
||||
service := &TranslationsStore{
|
||||
cache: make(TranslationResponse),
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *TranslationsStore) Get(langID uint, scope string, component string, key string) (string, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
if langID == 0 {
|
||||
return "lang_id_empty", ErrLangIsoEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID]; !ok {
|
||||
return fmt.Sprintf("lang_id_not_in_cache: %d", langID), ErrLangIsoNotFoundInCache
|
||||
}
|
||||
|
||||
if scope == "" {
|
||||
return "scope_empty", ErrScopeEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope]; !ok {
|
||||
return fmt.Sprintf("scope_not_in_cache: %s", scope), ErrScopeNotFoundInCache
|
||||
}
|
||||
|
||||
if component == "" {
|
||||
return "component_empty", ErrComponentEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope][component]; !ok {
|
||||
return fmt.Sprintf("component_not_in_cache: %s", component), ErrComponentNotFoundInCache
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
return "key_empty", ErrKeyEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope][component][key]; !ok {
|
||||
return fmt.Sprintf("key_invalid_in_cache: %s", key), ErrKeyNotFoundInCache
|
||||
}
|
||||
|
||||
return s.cache[langID][scope][component][key], nil
|
||||
}
|
||||
func (s *TranslationsStore) GetTranslations(langID uint, scope string, components []string) (*TranslationResponse, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
if langID == 0 {
|
||||
resp := make(TranslationResponse)
|
||||
for k, v := range s.cache {
|
||||
resp[k] = v
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID]; !ok {
|
||||
return nil, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
tr := make(TranslationResponse)
|
||||
|
||||
if scope == "" {
|
||||
tr[langID] = s.cache[langID]
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope]; !ok {
|
||||
return nil, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
tr[langID] = make(map[string]map[string]map[string]string)
|
||||
if len(components) <= 0 {
|
||||
tr[langID][scope] = s.cache[langID][scope]
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
tr[langID][scope] = make(map[string]map[string]string)
|
||||
|
||||
var invalidComponents []string
|
||||
for _, component := range components {
|
||||
if _, ok := s.cache[langID][scope][component]; !ok {
|
||||
invalidComponents = append(invalidComponents, component)
|
||||
continue
|
||||
}
|
||||
tr[langID][scope][component] = s.cache[langID][scope][component]
|
||||
}
|
||||
if len(invalidComponents) > 0 {
|
||||
return &tr, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
// GetAllTranslations returns all translations from the cache
|
||||
func (s *TranslationsStore) GetAllTranslations() *TranslationResponse {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
resp := make(TranslationResponse)
|
||||
for k, v := range s.cache {
|
||||
resp[k] = v
|
||||
}
|
||||
return &resp
|
||||
}
|
||||
|
||||
func (s *TranslationsStore) LoadTranslations(translations []model.Translation) error {
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
s.cache = make(TranslationResponse)
|
||||
for _, t := range translations {
|
||||
lang := uint(t.LangID)
|
||||
scp := t.Scope.Name
|
||||
cmp := t.Component.Name
|
||||
data := ""
|
||||
if t.Data != nil {
|
||||
data = *t.Data
|
||||
}
|
||||
|
||||
if _, ok := s.cache[lang]; !ok {
|
||||
s.cache[lang] = make(map[string]map[string]map[string]string)
|
||||
}
|
||||
if _, ok := s.cache[lang][scp]; !ok {
|
||||
s.cache[lang][scp] = make(map[string]map[string]string)
|
||||
}
|
||||
if _, ok := s.cache[lang][scp][cmp]; !ok {
|
||||
s.cache[lang][scp][cmp] = make(map[string]string)
|
||||
}
|
||||
s.cache[lang][scp][cmp][t.Key] = data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadTranslations reloads translations from the database
|
||||
func (s *TranslationsStore) ReloadTranslations(translations []model.Translation) error {
|
||||
return s.LoadTranslations(translations)
|
||||
}
|
||||
|
||||
// T_ is meant to be used to translate error messages and other system communicates.
|
||||
func T_[T ~string](c fiber.Ctx, key T, params ...interface{}) string {
|
||||
if langID, ok := c.Locals("langID").(uint); ok {
|
||||
parts := strings.Split(string(key), ".")
|
||||
|
||||
if len(parts) >= 2 {
|
||||
if trans, err := TransStore.Get(langID, "Backend", parts[0], strings.Join(parts[1:], ".")); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
} else {
|
||||
if trans, err := TransStore.Get(langID, "Backend", "be", parts[0]); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// T___ works exactly the same as T_ but uses just language ID instead of the whole context
|
||||
func T___[T ~string](langId uint, key T, params ...interface{}) string {
|
||||
parts := strings.Split(string(key), ".")
|
||||
|
||||
if len(parts) >= 2 {
|
||||
if trans, err := TransStore.Get(langId, "Backend", parts[0], strings.Join(parts[1:], ".")); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
} else {
|
||||
if trans, err := TransStore.Get(langId, "Backend", "be", parts[0]); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
}
|
||||
|
||||
return string(key)
|
||||
}
|
||||
|
||||
func Format(text string, params ...interface{}) string {
|
||||
text = fmt.Sprintf(text, params...)
|
||||
return text
|
||||
}
|
||||
|
||||
// T__ wraps T_ adding a conversion from string to template.HTML
|
||||
func T__(c fiber.Ctx, key string, params ...interface{}) template.HTML {
|
||||
return template.HTML(T_(c, key, params...))
|
||||
}
|
||||
|
||||
// MapKeyOnTranslationMap is a helper function to map keys on translation map
|
||||
// this is used to map keys on translation map
|
||||
//
|
||||
// example:
|
||||
// map := map[T]string{}
|
||||
// MapKeyOnTranslationMap(ctx, map, key1, key2, key3)
|
||||
func MapKeyOnTranslationMap[T ~string](c fiber.Ctx, m *map[T]string, key ...T) {
|
||||
if *m == nil {
|
||||
*m = make(map[T]string)
|
||||
}
|
||||
for _, k := range key {
|
||||
(*m)[k] = T_(c, string(k))
|
||||
}
|
||||
}
|
||||
74
app/utils/mapper/mapper.go
Normal file
74
app/utils/mapper/mapper.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package mapper
|
||||
|
||||
// Package mapper provides utilities to map fields from one struct to another
|
||||
// by matching field names (case-insensitive). Unmatched fields are left as
|
||||
// their zero values.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Map copies field values from src into dst by matching field names
|
||||
// (case-insensitive). Fields in dst that have no counterpart in src
|
||||
// are left at their zero value.
|
||||
//
|
||||
// Both dst and src must be pointers to structs.
|
||||
// Returns an error if the types do not satisfy those constraints.
|
||||
func Map(dst, src any) error {
|
||||
dstVal := reflect.ValueOf(dst)
|
||||
srcVal := reflect.ValueOf(src)
|
||||
|
||||
if dstVal.Kind() != reflect.Ptr || dstVal.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("mapper: dst must be a pointer to a struct, got %T", dst)
|
||||
}
|
||||
if srcVal.Kind() != reflect.Ptr || srcVal.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("mapper: src must be a pointer to a struct, got %T", src)
|
||||
}
|
||||
|
||||
dstElem := dstVal.Elem()
|
||||
srcElem := srcVal.Elem()
|
||||
|
||||
// Build a lookup map of src fields: lowercase name -> field value
|
||||
srcFields := make(map[string]reflect.Value)
|
||||
for i := 0; i < srcElem.NumField(); i++ {
|
||||
f := srcElem.Type().Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
srcFields[strings.ToLower(f.Name)] = srcElem.Field(i)
|
||||
}
|
||||
|
||||
// Iterate over dst fields and copy matching src values
|
||||
for i := 0; i < dstElem.NumField(); i++ {
|
||||
dstField := dstElem.Field(i)
|
||||
dstType := dstElem.Type().Field(i)
|
||||
|
||||
if !dstType.IsExported() || !dstField.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
srcField, ok := srcFields[strings.ToLower(dstType.Name)]
|
||||
if !ok {
|
||||
// No matching src field – leave zero value in place
|
||||
continue
|
||||
}
|
||||
|
||||
if srcField.Type().AssignableTo(dstField.Type()) {
|
||||
dstField.Set(srcField)
|
||||
} else if srcField.Type().ConvertibleTo(dstField.Type()) {
|
||||
dstField.Set(srcField.Convert(dstField.Type()))
|
||||
}
|
||||
// If neither assignable nor convertible, the dst field keeps its zero value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustMap is like Map but panics on error.
|
||||
func MustMap(dst, src any) {
|
||||
if err := Map(dst, src); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
78
app/utils/mapper/mapper_test.go
Normal file
78
app/utils/mapper/mapper_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package mapper_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/mapper"
|
||||
)
|
||||
|
||||
// --- example structs ---
|
||||
|
||||
type UserInput struct {
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
}
|
||||
|
||||
type UserRecord struct {
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
CreatedAt string // not in src → stays ""
|
||||
Active bool // not in src → stays false
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
func TestMap_MatchingFields(t *testing.T) {
|
||||
src := &UserInput{Name: "Alice", Email: "alice@example.com", Age: 30}
|
||||
dst := &UserRecord{}
|
||||
|
||||
if err := mapper.Map(dst, src); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if dst.Name != "Alice" {
|
||||
t.Errorf("Name: want Alice, got %s", dst.Name)
|
||||
}
|
||||
if dst.Email != "alice@example.com" {
|
||||
t.Errorf("Email: want alice@example.com, got %s", dst.Email)
|
||||
}
|
||||
if dst.Age != 30 {
|
||||
t.Errorf("Age: want 30, got %d", dst.Age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_UnmatchedFieldsAreZero(t *testing.T) {
|
||||
src := &UserInput{Name: "Bob"}
|
||||
dst := &UserRecord{}
|
||||
|
||||
mapper.MustMap(dst, src)
|
||||
|
||||
if dst.CreatedAt != "" {
|
||||
t.Errorf("CreatedAt: expected empty string, got %q", dst.CreatedAt)
|
||||
}
|
||||
if dst.Active != false {
|
||||
t.Errorf("Active: expected false, got %v", dst.Active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_TypeConversion(t *testing.T) {
|
||||
type Src struct{ Score int32 }
|
||||
type Dst struct{ Score int64 }
|
||||
|
||||
src := &Src{Score: 99}
|
||||
dst := &Dst{}
|
||||
|
||||
mapper.MustMap(dst, src)
|
||||
|
||||
if dst.Score != 99 {
|
||||
t.Errorf("Score: want 99, got %d", dst.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_InvalidInput(t *testing.T) {
|
||||
if err := mapper.Map("not a struct", 42); err == nil {
|
||||
t.Error("expected error for non-struct inputs")
|
||||
}
|
||||
}
|
||||
6
app/utils/nullable/nullable.go
Normal file
6
app/utils/nullable/nullable.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package nullable
|
||||
|
||||
//go:fix inline
|
||||
func GetNil[T any](in T) *T {
|
||||
return new(in)
|
||||
}
|
||||
63
app/utils/pagination/pagination.go
Normal file
63
app/utils/pagination/pagination.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Paging struct {
|
||||
Page uint `json:"page_number" example:"5"`
|
||||
Elements uint `json:"elements_per_page" example:"30"`
|
||||
}
|
||||
|
||||
func (p Paging) Offset() int {
|
||||
return int(p.Elements) * int(p.Page-1)
|
||||
}
|
||||
|
||||
func (p Paging) Limit() int {
|
||||
return int(p.Elements)
|
||||
}
|
||||
|
||||
type Found[T any] struct {
|
||||
Items []T `json:"items,omitempty"`
|
||||
Count uint `json:"items_count" example:"56"`
|
||||
}
|
||||
|
||||
func Paginate[T any](paging Paging, stmt *gorm.DB) (Found[T], error) {
|
||||
var items []T
|
||||
var count int64
|
||||
|
||||
base := stmt.Session(&gorm.Session{})
|
||||
|
||||
countDB := stmt.Session(&gorm.Session{
|
||||
NewDB: true, // critical: do NOT reuse statement
|
||||
})
|
||||
|
||||
if err := countDB.
|
||||
Table("(?) as sub", base).
|
||||
Count(&count).Error; err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
err := base.
|
||||
Offset(paging.Offset()).
|
||||
Limit(paging.Limit()).
|
||||
Find(&items).
|
||||
Error
|
||||
if err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
return Found[T]{
|
||||
Items: items,
|
||||
Count: uint(count),
|
||||
}, err
|
||||
}
|
||||
|
||||
func ParsePagination(c *fiber.Ctx) Paging {
|
||||
pageNum, _ := strconv.ParseInt((*c).Query("p", "1"), 10, 64)
|
||||
pageSize, _ := strconv.ParseInt((*c).Query("elems", "10"), 10, 64)
|
||||
return Paging{Page: uint(pageNum), Elements: uint(pageSize)}
|
||||
}
|
||||
10
app/utils/response/messages.go
Normal file
10
app/utils/response/messages.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package response
|
||||
|
||||
import "git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
|
||||
type ResponseMessage i18n.I18nTranslation
|
||||
|
||||
const (
|
||||
Message_OK ResponseMessage = "message_ok"
|
||||
Message_NOK ResponseMessage = "message_nok"
|
||||
)
|
||||
18
app/utils/response/response.go
Normal file
18
app/utils/response/response.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
type Response[T any] struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Items *T `json:"items,omitempty"`
|
||||
Count *int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
func Make[T any](c fiber.Ctx, status int, items *T, count *int, message string) Response[T] {
|
||||
c.Status(status)
|
||||
return Response[T]{
|
||||
Message: message,
|
||||
Items: items,
|
||||
Count: count,
|
||||
}
|
||||
}
|
||||
136
app/utils/version/version.go
Normal file
136
app/utils/version/version.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// Version info populated at build time
|
||||
var (
|
||||
Version string // Git tag or commit hash
|
||||
Commit string // Short commit hash
|
||||
BuildDate string // Build timestamp
|
||||
)
|
||||
|
||||
// Info returns version information
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
}
|
||||
|
||||
// GetInfo returns version information
|
||||
func GetInfo() Info {
|
||||
v := Info{
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
BuildDate: BuildDate,
|
||||
}
|
||||
|
||||
// If not set during build, try to get from git
|
||||
if v.Version == "" || v.Version == "unknown" || v.Version == "(devel)" {
|
||||
if gitVersion, gitCommit := getGitInfo(); gitVersion != "" {
|
||||
v.Version = gitVersion
|
||||
v.Commit = gitCommit
|
||||
}
|
||||
}
|
||||
|
||||
// If build date not set, use current time
|
||||
if v.BuildDate == "" {
|
||||
v.BuildDate = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// getGitInfo returns the latest tag or short commit hash and the commit hash
|
||||
func getGitInfo() (string, string) {
|
||||
// Get the current working directory
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Open the git repository
|
||||
repo, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Get the HEAD reference
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
commitHash := head.Hash().String()[:7]
|
||||
|
||||
// Get all tags
|
||||
tagIter, err := repo.Tags()
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// Get the commit for HEAD
|
||||
commit, err := repo.CommitObject(head.Hash())
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// Build ancestry map
|
||||
ancestry := make(map[string]bool)
|
||||
c := commit
|
||||
for c != nil {
|
||||
ancestry[c.Hash.String()] = true
|
||||
c, _ = c.Parent(0)
|
||||
}
|
||||
|
||||
// Find the most recent tag that's an ancestor of HEAD
|
||||
var latestTag string
|
||||
err = tagIter.ForEach(func(ref *plumbing.Reference) error {
|
||||
// Get the target commit
|
||||
targetHash := ref.Hash()
|
||||
|
||||
// Get the target commit
|
||||
targetCommit, err := repo.CommitObject(targetHash)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this tag is an ancestor of HEAD
|
||||
checkCommit := targetCommit
|
||||
for checkCommit != nil {
|
||||
if ancestry[checkCommit.Hash.String()] {
|
||||
// Extract tag name (remove refs/tags/ prefix)
|
||||
tagName := strings.TrimPrefix(ref.Name().String(), "refs/tags/")
|
||||
if latestTag == "" || tagName > latestTag {
|
||||
latestTag = tagName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
checkCommit, _ = checkCommit.Parent(0)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
if latestTag != "" {
|
||||
return latestTag, commitHash
|
||||
}
|
||||
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// String returns a formatted version string
|
||||
func String() string {
|
||||
info := GetInfo()
|
||||
return fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s\n", info.Version, info.Commit, info.BuildDate)
|
||||
}
|
||||
20
app/view/emails.go
Normal file
20
app/view/emails.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package view
|
||||
|
||||
type EmailLayout[T any] struct {
|
||||
LangID uint
|
||||
Data T
|
||||
}
|
||||
|
||||
type EmailVerificationData struct {
|
||||
VerificationURL string
|
||||
}
|
||||
|
||||
type EmailAdminNotificationData struct {
|
||||
UserName string
|
||||
UserEmail string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type EmailPasswordResetData struct {
|
||||
ResetURL string
|
||||
}
|
||||
166
app/view/errors.go
Normal file
166
app/view/errors.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
// Typed errors for request validation and authentication
|
||||
ErrInvalidBody = errors.New("invalid request body")
|
||||
ErrNotAuthenticated = errors.New("not authenticated")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrTokenExpired = errors.New("token has expired")
|
||||
ErrTokenRequired = errors.New("token is required")
|
||||
|
||||
// Typed errors for logging in and registering
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrEmailNotVerified = errors.New("email not verified")
|
||||
ErrEmailExists = errors.New("email already exists")
|
||||
ErrFirstLastNameRequired = errors.New("first and last name is required")
|
||||
ErrEmailRequired = errors.New("email is required")
|
||||
ErrEmailPasswordRequired = errors.New("email and password are required")
|
||||
ErrRefreshTokenRequired = errors.New("refresh token is required")
|
||||
|
||||
// Typed errors for password reset
|
||||
ErrInvalidResetToken = errors.New("invalid reset token")
|
||||
ErrResetTokenExpired = errors.New("reset token has expired")
|
||||
ErrPasswordsDoNotMatch = errors.New("passwords do not match")
|
||||
ErrInvalidPassword = errors.New("password must be at least 8 characters long and contain at least one lowercase letter, one uppercase letter, and one digit")
|
||||
ErrTokenPasswordRequired = errors.New("token and password are required")
|
||||
|
||||
// Typed errors for verification
|
||||
ErrInvalidVerificationToken = errors.New("invalid verification token")
|
||||
ErrVerificationTokenExpired = errors.New("verification token has expired")
|
||||
|
||||
// Typed errors for data extraction
|
||||
ErrBadRepoIDAttribute = errors.New("invalid repo id attribute")
|
||||
ErrBadYearAttribute = errors.New("invalid year attribute")
|
||||
ErrBadQuarterAttribute = errors.New("invalid quarter attribute")
|
||||
ErrBadPaging = errors.New("invalid paging")
|
||||
ErrInvalidRepoID = errors.New("repo not accessible")
|
||||
)
|
||||
|
||||
// Error represents an error with HTTP status code
|
||||
type Error struct {
|
||||
Err error
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewError creates a new typed error
|
||||
func NewError(err error, status int) *Error {
|
||||
return &Error{Err: err, Status: status}
|
||||
}
|
||||
|
||||
// GetErrorCode returns the error code string for HTTP response mapping
|
||||
func GetErrorCode(c fiber.Ctx, err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidBody):
|
||||
return i18n.T_(c, "error.err_invalid_body")
|
||||
case errors.Is(err, ErrInvalidCredentials):
|
||||
return i18n.T_(c, "error.err_invalid_credentials")
|
||||
case errors.Is(err, ErrNotAuthenticated):
|
||||
return i18n.T_(c, "error.err_not_authenticated")
|
||||
case errors.Is(err, ErrUserNotFound):
|
||||
return i18n.T_(c, "error.err_user_not_found")
|
||||
case errors.Is(err, ErrUserInactive):
|
||||
return i18n.T_(c, "error.err_user_inactive")
|
||||
case errors.Is(err, ErrEmailNotVerified):
|
||||
return i18n.T_(c, "error.err_email_not_verified")
|
||||
case errors.Is(err, ErrEmailExists):
|
||||
return i18n.T_(c, "error.err_email_exists")
|
||||
case errors.Is(err, ErrInvalidToken):
|
||||
return i18n.T_(c, "error.err_invalid_token")
|
||||
case errors.Is(err, ErrTokenExpired):
|
||||
return i18n.T_(c, "error.err_token_expired")
|
||||
case errors.Is(err, ErrFirstLastNameRequired):
|
||||
return i18n.T_(c, "error.err_first_last_name_required")
|
||||
case errors.Is(err, ErrEmailRequired):
|
||||
return i18n.T_(c, "error.err_email_required")
|
||||
case errors.Is(err, ErrEmailPasswordRequired):
|
||||
return i18n.T_(c, "error.err_email_password_required")
|
||||
case errors.Is(err, ErrTokenRequired):
|
||||
return i18n.T_(c, "error.err_token_required")
|
||||
case errors.Is(err, ErrRefreshTokenRequired):
|
||||
return i18n.T_(c, "error.err_refresh_token_required")
|
||||
|
||||
case errors.Is(err, ErrInvalidResetToken):
|
||||
return i18n.T_(c, "error.err_invalid_reset_token")
|
||||
case errors.Is(err, ErrResetTokenExpired):
|
||||
return i18n.T_(c, "error.err_reset_token_expired")
|
||||
case errors.Is(err, ErrPasswordsDoNotMatch):
|
||||
return i18n.T_(c, "error.err_passwords_do_not_match")
|
||||
case errors.Is(err, ErrInvalidPassword):
|
||||
return i18n.T_(c, "error.err_invalid_password")
|
||||
case errors.Is(err, ErrTokenPasswordRequired):
|
||||
return i18n.T_(c, "error.err_token_password_required")
|
||||
|
||||
case errors.Is(err, ErrInvalidVerificationToken):
|
||||
return i18n.T_(c, "error.err_invalid_verification_token")
|
||||
case errors.Is(err, ErrVerificationTokenExpired):
|
||||
return i18n.T_(c, "error.err_verification_token_expired")
|
||||
|
||||
case errors.Is(err, ErrBadRepoIDAttribute):
|
||||
return i18n.T_(c, "error.err_bad_repo_id_attribute")
|
||||
case errors.Is(err, ErrBadYearAttribute):
|
||||
return i18n.T_(c, "error.err_bad_year_attribute")
|
||||
case errors.Is(err, ErrBadQuarterAttribute):
|
||||
return i18n.T_(c, "error.err_bad_quarter_attribute")
|
||||
case errors.Is(err, ErrBadPaging):
|
||||
return i18n.T_(c, "error.err_bad_paging")
|
||||
case errors.Is(err, ErrInvalidRepoID):
|
||||
return i18n.T_(c, "error.err_invalid_repo_id")
|
||||
|
||||
default:
|
||||
return i18n.T_(c, "error.err_internal_server_error")
|
||||
}
|
||||
}
|
||||
|
||||
// GetErrorStatus returns the HTTP status code for the given error
|
||||
func GetErrorStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials),
|
||||
errors.Is(err, ErrNotAuthenticated),
|
||||
errors.Is(err, ErrInvalidToken),
|
||||
errors.Is(err, ErrTokenExpired):
|
||||
return fiber.StatusUnauthorized
|
||||
case errors.Is(err, ErrInvalidBody),
|
||||
errors.Is(err, ErrUserNotFound),
|
||||
errors.Is(err, ErrUserInactive),
|
||||
errors.Is(err, ErrEmailNotVerified),
|
||||
errors.Is(err, ErrFirstLastNameRequired),
|
||||
errors.Is(err, ErrEmailRequired),
|
||||
errors.Is(err, ErrEmailPasswordRequired),
|
||||
errors.Is(err, ErrTokenRequired),
|
||||
errors.Is(err, ErrRefreshTokenRequired),
|
||||
errors.Is(err, ErrPasswordsDoNotMatch),
|
||||
errors.Is(err, ErrTokenPasswordRequired),
|
||||
errors.Is(err, ErrInvalidResetToken),
|
||||
errors.Is(err, ErrResetTokenExpired),
|
||||
errors.Is(err, ErrInvalidVerificationToken),
|
||||
errors.Is(err, ErrVerificationTokenExpired),
|
||||
errors.Is(err, ErrInvalidPassword),
|
||||
errors.Is(err, ErrBadRepoIDAttribute),
|
||||
errors.Is(err, ErrBadYearAttribute),
|
||||
errors.Is(err, ErrBadQuarterAttribute),
|
||||
errors.Is(err, ErrBadPaging),
|
||||
errors.Is(err, ErrInvalidRepoID):
|
||||
return fiber.StatusBadRequest
|
||||
case errors.Is(err, ErrEmailExists):
|
||||
return fiber.StatusConflict
|
||||
default:
|
||||
return fiber.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
21
app/view/i18n.go
Normal file
21
app/view/i18n.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Language struct {
|
||||
ID uint64 `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ISOCode string `json:"iso_code"`
|
||||
LangCode string `json:"lang_code"`
|
||||
DateFormat string `json:"date_format"`
|
||||
DateFormatShort string `json:"date_format_short"`
|
||||
RTL bool `json:"rtl"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Active bool `json:"active"`
|
||||
Flag string `json:"flag"`
|
||||
}
|
||||
36
app/view/repo.go
Normal file
36
app/view/repo.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
|
||||
)
|
||||
|
||||
type RepositoryChartData struct {
|
||||
Years []uint
|
||||
Quarters []model.QuarterData
|
||||
QuartersJSON string
|
||||
Year uint
|
||||
}
|
||||
|
||||
type TimeTrackedData struct {
|
||||
RepoId uint
|
||||
Year uint
|
||||
Quarter uint
|
||||
Step string
|
||||
TotalTime float64
|
||||
DailyData []model.DayData
|
||||
DailyDataJSON string
|
||||
Years []uint
|
||||
IssueSummaries *pagination.Found[IssueTimeSummary]
|
||||
}
|
||||
|
||||
type IssueTimeSummary struct {
|
||||
IssueID uint `gorm:"column:issue_id"`
|
||||
IssueName string `gorm:"column:issue_name"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Initials string `gorm:"column:initials"`
|
||||
CreatedDate time.Time `gorm:"column:created_date"`
|
||||
TotalHoursSpent float64 `gorm:"column:total_hours_spent"`
|
||||
}
|
||||
16
assets/dev.go
Normal file
16
assets/dev.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !embed
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
func FS() fs.FS {
|
||||
return os.DirFS("assets/public") // <-- dev root matches embedded
|
||||
}
|
||||
|
||||
func FSDist() fs.FS {
|
||||
return os.DirFS("assets/public/dist") // <-- dev root matches embedded
|
||||
}
|
||||
30
assets/prod.go
Normal file
30
assets/prod.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build embed
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:public
|
||||
var Embedded embed.FS
|
||||
|
||||
//go:embed all:public/dist
|
||||
var EmbeddedDist embed.FS
|
||||
|
||||
func FS() fs.FS {
|
||||
sub, err := fs.Sub(Embedded, "public")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func FSDist() fs.FS {
|
||||
sub, err := fs.Sub(EmbeddedDist, "public/dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
1
assets/public/dist/assets/Alert-BNRo6CMI.js
vendored
Normal file
1
assets/public/dist/assets/Alert-BNRo6CMI.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/Button-jwL-tYHc.js
vendored
Normal file
1
assets/public/dist/assets/Button-jwL-tYHc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/Card-DPC9xXwj.js
vendored
Normal file
1
assets/public/dist/assets/Card-DPC9xXwj.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,G as t,Q as n,R as r,d as i,h as a,m as o,p as s,xt as c,yt as l}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{S as u,i as d,n as f,r as p}from"./Icon-Chkiq2IE.js";var m={slots:{root:`rounded-lg overflow-hidden`,header:`p-4 sm:px-6`,body:`p-4 sm:p-6`,footer:`p-4 sm:px-6`},variants:{variant:{solid:{root:`bg-inverted text-inverted`},outline:{root:`bg-default ring ring-default divide-y divide-default`},soft:{root:`bg-elevated/50 divide-y divide-default`},subtle:{root:`bg-elevated/50 ring ring-default divide-y divide-default`}}},defaultVariants:{variant:`outline`}},h={__name:`Card`,props:{as:{type:null,required:!1},variant:{type:null,required:!1},class:{type:null,required:!1},ui:{type:Object,required:!1}},setup(h){let g=h,_=t(),v=u(),y=f(`card`,g),b=i(()=>p({extend:p(m),...v.ui?.card||{}})({variant:g.variant}));return(t,i)=>(e(),s(l(d),{as:h.as,"data-slot":`root`,class:c(b.value.root({class:[l(y)?.root,g.class]}))},{default:n(()=>[_.header?(e(),a(`div`,{key:0,"data-slot":`header`,class:c(b.value.header({class:l(y)?.header}))},[r(t.$slots,`header`)],2)):o(``,!0),_.default?(e(),a(`div`,{key:1,"data-slot":`body`,class:c(b.value.body({class:l(y)?.body}))},[r(t.$slots,`default`)],2)):o(``,!0),_.footer?(e(),a(`div`,{key:2,"data-slot":`footer`,class:c(b.value.footer({class:l(y)?.footer}))},[r(t.$slots,`footer`)],2)):o(``,!0)]),_:3},8,[`as`,`class`]))}};export{h as t};
|
||||
1
assets/public/dist/assets/Collection-BkGqWqUl.js
vendored
Normal file
1
assets/public/dist/assets/Collection-BkGqWqUl.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{I as e,J as t,S as n,Y as r,_t as i,d as a,ot as o,ut as s,w as c,y as l}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{E as u,a as d}from"./Icon-Chkiq2IE.js";import{f}from"./usePortal-Zddbph8M.js";function p(e){let t=f({dir:s(`ltr`)});return a(()=>e?.value||t.dir?.value||`ltr`)}function m(e){return a(()=>i(e)?!!u(e)?.closest(`form`):!0)}function h(){let e=s();return{primitiveElement:e,currentElement:a(()=>[`#text`,`#comment`].includes(e.value?.$el.nodeName)?e.value?.$el.nextElementSibling:u(e))}}var g=`data-reka-collection-item`;function _(i={}){let{key:u=``,isProvider:f=!1}=i,p=`${u}CollectionProvider`,m;if(f){let t=s(new Map);m={collectionRef:s(),itemMap:t},e(p,m)}else m=c(p);let _=(e=!1)=>{let t=m.collectionRef.value;if(!t)return[];let n=Array.from(t.querySelectorAll(`[${g}]`)),r=Array.from(m.itemMap.value.values()).sort((e,t)=>n.indexOf(e.ref)-n.indexOf(t.ref));return e?r:r.filter(e=>e.ref.dataset.disabled!==``)},v=l({name:`CollectionSlot`,inheritAttrs:!1,setup(e,{slots:r,attrs:i}){let{primitiveElement:a,currentElement:o}=h();return t(o,()=>{m.collectionRef.value=o.value}),()=>n(d,{ref:a,...i},r)}}),y=l({name:`CollectionItem`,inheritAttrs:!1,props:{value:{validator:()=>!0}},setup(e,{slots:t,attrs:i}){let{primitiveElement:a,currentElement:s}=h();return r(t=>{if(s.value){let n=o(s.value);m.itemMap.value.set(n,{ref:s.value,value:e.value}),t(()=>m.itemMap.value.delete(n))}}),()=>n(d,{...i,[g]:``,ref:a},t)}});return{getItems:_,reactiveItems:a(()=>Array.from(m.itemMap.value.values())),itemMapSize:a(()=>m.itemMap.value.size),CollectionSlot:v,CollectionItem:y}}export{p as i,h as n,m as r,_ as t};
|
||||
1
assets/public/dist/assets/HomeView-BQahLZXc.js
vendored
Normal file
1
assets/public/dist/assets/HomeView-BQahLZXc.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{t as e}from"./HomeView-CdMOMcn8.js";export{e as default};
|
||||
1
assets/public/dist/assets/HomeView-CdMOMcn8.js
vendored
Normal file
1
assets/public/dist/assets/HomeView-CdMOMcn8.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,Q as t,_ as n,g as r,h as i,z as a}from"./vue.runtime.esm-bundler-BM5WPBHd.js";var o=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},s={},c={class:`flex gap-4`};function l(o,s){let l=a(`RouterLink`);return e(),i(`main`,c,[n(l,{class:`bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md`,to:{name:`login`}},{default:t(()=>[...s[0]||=[r(`Login `,-1)]]),_:1}),n(l,{class:`bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md`,to:{name:`register`}},{default:t(()=>[...s[1]||=[r(` Register`,-1)]]),_:1}),n(l,{class:`bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md`,to:{name:`chart`}},{default:t(()=>[...s[2]||=[r(`Chart `,-1)]]),_:1})])}var u=o(s,[[`render`,l]]);export{u as t};
|
||||
3
assets/public/dist/assets/Icon-Chkiq2IE.js
vendored
Normal file
3
assets/public/dist/assets/Icon-Chkiq2IE.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/public/dist/assets/LoginView-DckqZJ4W.js
vendored
Normal file
2
assets/public/dist/assets/LoginView-DckqZJ4W.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/PasswordRecoveryView-BsywcP-S.js
vendored
Normal file
1
assets/public/dist/assets/PasswordRecoveryView-BsywcP-S.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,Q as t,_ as n,f as r,g as i,h as a,m as o,o as s,p as c,ut as l,wt as u,y as d,yt as f}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import"./useFetchJson-4WJQFaEO.js";import{n as p}from"./useForwardExpose-BgPOLLFN.js";import{Q as m,X as h,t as g}from"./Icon-Chkiq2IE.js";import{t as _}from"./auth-hZSBdvj-.js";import{t as v}from"./Button-jwL-tYHc.js";import{n as y,r as b,t as x}from"./useValidation-wBItIFut.js";import{n as S}from"./settings-BcOmX106.js";import{t as C}from"./Alert-BNRo6CMI.js";var w={class:`h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8`},T={class:`w-full max-w-md flex flex-col gap-4`},E={key:0,class:`text-center flex flex-col gap-4`},D={class:`text-xl font-semibold dark:text-white text-black`},O={class:`text-sm text-gray-600 dark:text-gray-400`},k={class:`text-center`},A={class:`text-sm text-gray-600 dark:text-gray-400`},j={class:`text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4`},M={class:`text-sm text-gray-600 dark:text-gray-400`},N=d({__name:`PasswordRecoveryView`,setup(d){let{t:N}=m(),P=h(),F=_(),I=x(),L=l(``),R=l(!1);async function z(){await F.requestPasswordReset(L.value)&&(R.value=!0)}function B(){P.push({name:`login`})}function V(){P.push({name:`register`})}function H(){return I.reset(),I.validateEmail(L,`email`,p.t(`validate_error.email_required`)),I.errors}return(l,d)=>{let p=g,m=v,h=C,_=S,x=y,N=b;return e(),a(`div`,w,[r(`div`,T,[R.value?(e(),a(`div`,E,[n(p,{name:`i-heroicons-envelope`,class:`w-12 h-12 mx-auto text-primary-500`}),r(`h2`,D,u(l.$t(`general.check_your_email`)),1),r(`p`,O,u(l.$t(`general.password_reset_link_sent_notice`)),1),n(m,{color:`neutral`,variant:`outline`,block:``,onClick:B,class:`dark:text-white text-black`},{default:t(()=>[i(u(l.$t(`general.back_to_sign_in`)),1)]),_:1})])):(e(),a(s,{key:1},[r(`div`,k,[r(`p`,A,u(l.$t(`general.enter_email_for_password_reset`)),1)]),n(N,{validate:H,onSubmit:z,class:`flex flex-col gap-3`},{default:t(()=>[f(F).error?(e(),c(h,{key:0,color:`error`,variant:`subtle`,icon:`i-heroicons-exclamation-triangle`,title:f(F).error,"close-button":{icon:`i-heroicons-x-mark-20-solid`,variant:`link`},onClose:f(F).clearError},null,8,[`title`,`onClose`])):o(``,!0),n(x,{label:l.$t(`general.email_address`),name:`email`,required:``,class:`w-full dark:text-white text-black`},{default:t(()=>[n(_,{modelValue:L.value,"onUpdate:modelValue":d[0]||=e=>L.value=e,placeholder:l.$t(`general.enter_your_email`),disabled:f(F).loading,class:`w-full dark:text-white text-black`},null,8,[`modelValue`,`placeholder`,`disabled`])]),_:1},8,[`label`]),n(m,{type:`submit`,block:``,loading:f(F).loading,class:`text-white bg-(--color-blue-600) dark:bg-(--color-blue-500)`},{default:t(()=>[i(u(l.$t(`general.send_password_reset_link`)),1)]),_:1},8,[`loading`])]),_:1}),r(`div`,j,[n(m,{color:`neutral`,variant:`outline`,loading:f(F).loading,class:`w-full flex justify-center dark:text-white text-black`,onClick:B},{default:t(()=>[i(u(l.$t(`general.back_to_sign_in`)),1)]),_:1},8,[`loading`]),r(`p`,M,[i(u(l.$t(`general.dont_have_an_account`))+` `,1),n(m,{variant:`link`,size:`sm`,onClick:V,class:`text-(--color-blue-600) dark:text-(--color-blue-500)`},{default:t(()=>[i(u(l.$t(`general.create_account_now`)),1)]),_:1})])])],64))])])}}});export{N as default};
|
||||
1
assets/public/dist/assets/PopperArrow-CcUKYeE0.js
vendored
Normal file
1
assets/public/dist/assets/PopperArrow-CcUKYeE0.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/public/dist/assets/RegisterView-HW42R58H.js
vendored
Normal file
2
assets/public/dist/assets/RegisterView-HW42R58H.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
assets/public/dist/assets/RepoChartView-DWk8UojC.js
vendored
Normal file
3
assets/public/dist/assets/RepoChartView-DWk8UojC.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/ResetPasswordForm-Bm9Fa-4w.js
vendored
Normal file
1
assets/public/dist/assets/ResetPasswordForm-Bm9Fa-4w.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,M as t,Q as n,_ as r,f as i,g as a,h as o,m as s,p as c,ut as l,wt as u,y as d,yt as f}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import"./useFetchJson-4WJQFaEO.js";import{n as p}from"./useForwardExpose-BgPOLLFN.js";import{Q as m,X as h,Y as g,t as _}from"./Icon-Chkiq2IE.js";import{t as v}from"./auth-hZSBdvj-.js";import{t as y}from"./Button-jwL-tYHc.js";import{n as b,r as x,t as S}from"./useValidation-wBItIFut.js";import{n as C}from"./settings-BcOmX106.js";import{t as w}from"./Alert-BNRo6CMI.js";var T={class:`h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8`},E={class:`w-full max-w-md flex flex-col gap-4`},D={key:0,class:`text-center flex flex-col gap-4`},O={class:`text-xl font-semibold dark:text-white text-black`},k={class:`text-sm text-gray-600 dark:text-gray-400`},A={class:`text-center border-t dark:border-(--border-dark) border-(--border-light) pt-4`},j=d({__name:`ResetPasswordForm`,setup(d){let{t:j}=m(),M=h(),N=g(),P=v(),F=S(),I=l(``),L=l(``),R=l(!1),z=l(!1),B=l(``),V=l(!1);t(()=>{B.value=N.query.token||``,B.value||M.push({name:`password-recovery`})});async function H(){await P.resetPassword(B.value,I.value)&&(V.value=!0)}function U(){M.push({name:`login`})}function W(){return F.reset(),F.validatePasswords(I,`new_password`,L,`confirm_new_password`,p.t(`validate_error.confirm_password_required`)),F.errors}return(t,l)=>{let d=_,p=y,m=w,h=C,g=b,v=x;return e(),o(`div`,T,[i(`div`,E,[V.value?(e(),o(`div`,D,[r(d,{name:`i-heroicons-check-circle`,class:`w-12 h-12 mx-auto text-green-500`}),i(`h2`,O,u(t.$t(`general.password_updated`)),1),i(`p`,k,u(t.$t(`general.password_updated_description`)),1),r(p,{block:``,onClick:U,class:`dark:text-white text-black`},{default:n(()=>[a(u(t.$t(`general.back_to_sign_in`)),1)]),_:1})])):(e(),c(v,{key:1,validate:W,onSubmit:H,class:`flex flex-col gap-3`},{default:n(()=>[f(P).error?(e(),c(m,{key:0,color:`error`,variant:`subtle`,icon:`i-heroicons-exclamation-triangle`,title:f(P).error,"close-button":{icon:`i-heroicons-x-mark-20-solid`,variant:`link`},onClose:f(P).clearError},null,8,[`title`,`onClose`])):s(``,!0),r(g,{label:t.$t(`general.new_password`),name:`new_password`,required:``,class:`w-full dark:text-white text-black`},{default:n(()=>[r(h,{modelValue:I.value,"onUpdate:modelValue":l[1]||=e=>I.value=e,type:R.value?`text`:`password`,placeholder:t.$t(`general.enter_your_new_password`),disabled:f(P).loading,class:`w-full dark:text-white text-black`,ui:{trailing:`pe-1`}},{trailing:n(()=>[r(d,{color:`neutral`,variant:`link`,size:`sm`,name:R.value?`i-lucide-eye-off`:`i-lucide-eye`,"aria-label":R.value?`Hide password`:`Show password`,"aria-pressed":R.value,"aria-controls":`new_password`,onClick:l[0]||=e=>R.value=!R.value},null,8,[`name`,`aria-label`,`aria-pressed`])]),_:1},8,[`modelValue`,`type`,`placeholder`,`disabled`])]),_:1},8,[`label`]),r(g,{label:t.$t(`general.confirm_password`),name:`confirm_new_password`,required:``,class:`w-full dark:text-white text-black`},{default:n(()=>[r(h,{modelValue:L.value,"onUpdate:modelValue":l[3]||=e=>L.value=e,type:z.value?`text`:`password`,placeholder:t.$t(`general.confirm_your_new_password`),disabled:f(P).loading,class:`w-full dark:text-white text-black`,ui:{trailing:`pe-1`}},{trailing:n(()=>[r(d,{color:`neutral`,variant:`ghost`,size:`sm`,name:z.value?`i-lucide-eye-off`:`i-lucide-eye`,onClick:l[2]||=e=>z.value=!z.value},null,8,[`name`])]),_:1},8,[`modelValue`,`type`,`placeholder`,`disabled`])]),_:1},8,[`label`]),r(p,{type:`submit`,block:``,loading:f(P).loading,class:`text-white! bg-(--color-blue-600) dark:bg-(--color-blue-500)`},{default:n(()=>[a(u(t.$t(`general.reset_password`)),1)]),_:1},8,[`loading`]),i(`div`,A,[r(p,{color:`neutral`,variant:`ghost`,onClick:U,class:`dark:text-white text-black`},{default:n(()=>[a(u(t.$t(`general.back_to_sign_in`)),1)]),_:1})])]),_:1}))])])}}});export{j as default};
|
||||
1
assets/public/dist/assets/VerifyEmailView-B2adokLx.js
vendored
Normal file
1
assets/public/dist/assets/VerifyEmailView-B2adokLx.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,M as t,Q as n,_ as r,f as i,g as a,h as o,m as s,ut as c,wt as l,y as u}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{t as d}from"./useFetchJson-4WJQFaEO.js";import{Q as f,X as p,Y as m,t as h}from"./Icon-Chkiq2IE.js";import{t as g}from"./Button-jwL-tYHc.js";import{t as _}from"./Card-DPC9xXwj.js";import{t as v}from"./Alert-BNRo6CMI.js";var y={class:`min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900`},b={class:`pt-20 pb-8 flex items-center justify-center px-4 sm:px-6 lg:px-8`},x={class:`w-full max-w-md`},S={class:`text-center mb-8`},C={class:`inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30`},w={class:`text-center`},T={key:0},E={class:`text-xl font-semibold text-gray-900 dark:text-white`},D={key:1},O={class:`inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 text-green-600 mb-4`},k={class:`text-xl font-semibold text-gray-900 dark:text-white`},A={class:`mt-1 text-sm text-gray-500 dark:text-gray-400`},j={key:2},M={class:`inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 text-red-600 mb-4`},N={class:`text-xl font-semibold text-gray-900 dark:text-white`},P={class:`mt-1 text-sm text-gray-500 dark:text-gray-400`},F={key:0,class:`text-center py-4`},I={class:`text-gray-600 dark:text-gray-400 mb-4`},L={key:1,class:`text-center py-4`},R={key:2,class:`text-center py-4`},z={class:`text-gray-500 dark:text-gray-400`},B={class:`text-center`},V={class:`text-sm text-gray-600 dark:text-gray-400`},H=u({__name:`VerifyEmailView`,setup(u){let{t:H,te:U}=f(),W=p(),G=m();function K(e,t){return U(e)?H(e):t}let q=c(``),J=c(!1),Y=c(null),X=c(!1),Z=c(!0);t(()=>{if(q.value=G.query.token||``,!q.value){Y.value=K(`verify_email.invalid_token`,`Invalid or missing verification token`),Z.value=!1;return}Q()});async function Q(){if(!q.value){Y.value=K(`verify_email.invalid_token`,`Invalid or missing verification token`);return}J.value=!0,Y.value=null;try{await d(`/api/v1/auth/complete-registration`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({token:q.value})}),X.value=!0,Z.value=!1,setTimeout(()=>{W.push({name:`login`})},3e3)}catch(e){Y.value=e?.message??K(`verify_email.verification_failed`,`Email verification failed`),Z.value=!1}finally{J.value=!1}}function $(){W.push({name:`login`})}return(t,c)=>{let u=h,d=g,f=v,p=_;return e(),o(`div`,y,[i(`div`,b,[i(`div`,x,[i(`div`,S,[i(`div`,C,[r(u,{name:`i-heroicons-envelope-check`,class:`w-8 h-8`})]),c[0]||=i(`h1`,{class:`text-3xl font-bold text-gray-900 dark:text-white`},`TimeTracker`,-1)]),r(p,{class:`shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50`},{header:n(()=>[i(`div`,w,[Z.value&&J.value?(e(),o(`div`,T,[r(u,{name:`i-heroicons-arrow-path`,class:`w-8 h-8 animate-spin text-primary-500 mx-auto mb-4`}),i(`h2`,E,l(K(`verify_email.verifying`,`Verifying your email...`)),1)])):X.value?(e(),o(`div`,D,[i(`div`,O,[r(u,{name:`i-heroicons-check-circle`,class:`w-6 h-6`})]),i(`h2`,k,l(K(`verify_email.success_title`,`Email Verified!`)),1),i(`p`,A,l(K(`verify_email.success_message`,`Your email has been verified successfully.`)),1)])):Y.value?(e(),o(`div`,j,[i(`div`,M,[r(u,{name:`i-heroicons-exclamation-circle`,class:`w-6 h-6`})]),i(`h2`,N,l(K(`verify_email.error_title`,`Verification Failed`)),1),i(`p`,P,l(K(`verify_email.error_message`,`We could not verify your email.`)),1)])):s(``,!0)])]),footer:n(()=>[i(`div`,B,[i(`p`,V,[a(l(K(`verify_email.already_registered`,`Already have an account?`))+` `,1),r(d,{variant:`link`,size:`sm`,onClick:$},{default:n(()=>[a(l(K(`verify_email.sign_in`,`Sign in`)),1)]),_:1})])])]),default:n(()=>[X.value?(e(),o(`div`,F,[i(`p`,I,l(K(`verify_email.redirect_message`,`You will be redirected to login page...`)),1),r(d,{color:`primary`,onClick:$},{default:n(()=>[a(l(K(`verify_email.go_to_login`,`Go to Login`)),1)]),_:1})])):Y.value?(e(),o(`div`,L,[r(f,{color:`error`,variant:`subtle`,icon:`i-heroicons-exclamation-triangle`,title:Y.value,class:`mb-4`},null,8,[`title`]),r(d,{color:`primary`,onClick:$},{default:n(()=>[a(l(K(`verify_email.go_to_login`,`Go to Login`)),1)]),_:1})])):Z.value&&J.value?(e(),o(`div`,R,[i(`p`,z,l(K(`verify_email.please_wait`,`Please wait while we verify your email address.`)),1)])):s(``,!0)]),_:1})])])])}}});export{H as default};
|
||||
1
assets/public/dist/assets/VisuallyHiddenInput-BH1aLUkb.js
vendored
Normal file
1
assets/public/dist/assets/VisuallyHiddenInput-BH1aLUkb.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{D as e,F as t,J as n,L as r,d as i,h as a,m as o,o as s,p as c,y as l}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{h as u,n as d}from"./usePortal-Zddbph8M.js";import{n as f}from"./Collection-BkGqWqUl.js";var p={ArrowLeft:`prev`,ArrowUp:`prev`,ArrowRight:`next`,ArrowDown:`next`,PageUp:`first`,Home:`first`,PageDown:`last`,End:`last`};function m(e,t){return t===`rtl`?e===`ArrowLeft`?`ArrowRight`:e===`ArrowRight`?`ArrowLeft`:e:e}function h(e,t,n){let r=m(e.key,n);if(!(t===`vertical`&&[`ArrowLeft`,`ArrowRight`].includes(r))&&!(t===`horizontal`&&[`ArrowUp`,`ArrowDown`].includes(r)))return p[r]}function g(e,t=!1){let n=u();for(let r of e)if(r===n||(r.focus({preventScroll:t}),u()!==n))return}function _(e,t){return e.map((n,r)=>e[(t+r)%e.length])}var v=l({inheritAttrs:!1,__name:`VisuallyHiddenInputBubble`,props:{name:{type:String,required:!0},value:{type:null,required:!0},checked:{type:Boolean,required:!1,default:void 0},required:{type:Boolean,required:!1},disabled:{type:Boolean,required:!1},feature:{type:String,required:!1,default:`fully-hidden`}},setup(r){let a=r,{primitiveElement:o,currentElement:s}=f();return n(i(()=>a.checked??a.value),(e,t)=>{if(!s.value)return;let n=s.value,r=window.HTMLInputElement.prototype,i=Object.getOwnPropertyDescriptor(r,`value`).set;if(i&&e!==t){let t=new Event(`input`,{bubbles:!0}),r=new Event(`change`,{bubbles:!0});i.call(n,e),n.dispatchEvent(t),n.dispatchEvent(r)}}),(n,r)=>(t(),c(d,e({ref_key:`primitiveElement`,ref:o},{...a,...n.$attrs},{as:`input`}),null,16))}}),y=l({inheritAttrs:!1,__name:`VisuallyHiddenInput`,props:{name:{type:String,required:!0},value:{type:null,required:!0},checked:{type:Boolean,required:!1,default:void 0},required:{type:Boolean,required:!1},disabled:{type:Boolean,required:!1},feature:{type:String,required:!1,default:`fully-hidden`}},setup(n){let l=n,u=i(()=>typeof l.value==`object`&&Array.isArray(l.value)&&l.value.length===0&&l.required),d=i(()=>typeof l.value==`string`||typeof l.value==`number`||typeof l.value==`boolean`||l.value===null||l.value===void 0?[{name:l.name,value:l.value}]:typeof l.value==`object`&&Array.isArray(l.value)?l.value.flatMap((e,t)=>typeof e==`object`?Object.entries(e).map(([e,n])=>({name:`${l.name}[${t}][${e}]`,value:n})):{name:`${l.name}[${t}]`,value:e}):l.value!==null&&typeof l.value==`object`&&!Array.isArray(l.value)?Object.entries(l.value).map(([e,t])=>({name:`${l.name}[${e}]`,value:t})):[]);return(n,i)=>(t(),a(s,null,[o(` We render single input if it's required `),u.value?(t(),c(v,e({key:n.name},{...l,...n.$attrs},{name:n.name,value:n.value}),null,16,[`name`,`value`])):(t(!0),a(s,{key:1},r(d.value,r=>(t(),c(v,e({key:r.name},{ref_for:!0},{...l,...n.$attrs},{name:r.name,value:r.value}),null,16,[`name`,`value`]))),128))],2112))}});export{_ as a,h as i,p as n,g as r,y as t};
|
||||
1
assets/public/dist/assets/_rolldown_dynamic_import_helper-DhxqfwDR.js
vendored
Normal file
1
assets/public/dist/assets/_rolldown_dynamic_import_helper-DhxqfwDR.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/auth-CdHmhksw.js
vendored
Normal file
1
assets/public/dist/assets/auth-CdHmhksw.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import"./useFetchJson-4WJQFaEO.js";import{t as e}from"./auth-hZSBdvj-.js";export{e as useAuthStore};
|
||||
2
assets/public/dist/assets/auth-hZSBdvj-.js
vendored
Normal file
2
assets/public/dist/assets/auth-hZSBdvj-.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/cs_PrivacyPolicyView-Be5X4T0B.js
vendored
Normal file
1
assets/public/dist/assets/cs_PrivacyPolicyView-Be5X4T0B.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/cs_TermsAndConditionsView-DP2Pp5Ho.js
vendored
Normal file
1
assets/public/dist/assets/cs_TermsAndConditionsView-DP2Pp5Ho.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,Q as t,_ as n,f as r,h as i,y as a}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{Q as o,t as s}from"./Icon-Chkiq2IE.js";import{t as c}from"./Card-DPC9xXwj.js";var l={class:`min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8`},u={class:`max-w-4xl mx-auto`},d={class:`text-center mb-12`},f={class:`inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30`},p=a({__name:`cs_TermsAndConditionsView`,setup(a){let{t:p}=o();return(a,o)=>{let p=s,m=c;return e(),i(`div`,l,[r(`div`,u,[r(`div`,d,[r(`div`,f,[n(p,{name:`i-heroicons-document-text`,class:`w-8 h-8`})]),o[0]||=r(`h1`,{class:`text-3xl font-bold text-gray-900 dark:text-white`},`Podmínky použití`,-1),o[1]||=r(`p`,{class:`mt-2 text-sm text-gray-600 dark:text-gray-400`},`Poslední aktualizace: březen 2026`,-1)]),n(m,{class:`shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50`},{footer:t(()=>[...o[2]||=[r(`div`,{class:`flex justify-center`},null,-1)]]),default:t(()=>[o[3]||=r(`div`,{class:`prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6`},[r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`1. Přijetí podmínek`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Používáním aplikace TimeTracker souhlasíte a zavazujete se dodržovat podmínky a ustanovení této dohody. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`2. Popis služby`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` TimeTracker je aplikace pro sledování času, která uživatelům umožňuje sledovat pracovní hodiny, spravovat projekty a generovat reporty. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`3. Odpovědnosti uživatele`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},`Souhlasíte s:`),r(`ul`,{class:`list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4`},[r(`li`,null,`Poskytováním přesných a úplných informací`),r(`li`,null,`Udržováním bezpečnosti svého účtu`),r(`li`,null,`Nesdílením přihlašovacích údajů s ostatními`),r(`li`,null,`Používáním služby v souladu s platnými zákony`)])]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`4. Ochrana osobních údajů`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Jsme odhodláni chránit vaše soukromí. Vaše osobní údaje budou zpracovány v souladu s naší Zásadami ochrany osobních údajů a příslušnými zákony o ochraně dat. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`5. Duševní vlastnictví`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Služba TimeTracker a veškerý její obsah, včetně mimo jiné textů, grafiky, loga a softwaru, je majetkem TimeTracker a je chráněn zákony o duševním vlastnictví. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`6. Omezení odpovědnosti`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` TimeTracker neodpovídá za jakékoli nepřímé, náhodné, zvláštní, následné nebo trestné škody vzniklé v důsledku vašeho používání nebo neschopnosti používat službu. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`7. Ukončení`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Vyhrazujeme si právo ukončit nebo pozastavit váš účet kdykoli, bez předchozího upozornění, za chování, které por tyto Podmušujeínky použití nebo je škodlivé pro ostatní uživatele nebo službu. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`8. Změny podmínek`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Vyhrazujeme si právo kdykoli upravit tyto Podmínky použití. Vaše další používání TimeTracker po jakýchkoli změnách znamená přijetí nových podmínek. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`9. Kontaktní informace`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Máte-li jakékoli dotazy ohledně těchto Podmínek použití, kontaktujte nás na adrese support@timetracker.com. `)])],-1)]),_:1})])])}}});export{p as default};
|
||||
1
assets/public/dist/assets/en_PrivacyPolicyView-C0wuScgt.js
vendored
Normal file
1
assets/public/dist/assets/en_PrivacyPolicyView-C0wuScgt.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/en_TermsAndConditionsView-C9vscF7i.js
vendored
Normal file
1
assets/public/dist/assets/en_TermsAndConditionsView-C9vscF7i.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,Q as t,_ as n,f as r,h as i,y as a}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{Q as o,t as s}from"./Icon-Chkiq2IE.js";import{t as c}from"./Card-DPC9xXwj.js";var l={class:`min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8`},u={class:`max-w-4xl mx-auto`},d={class:`text-center mb-12`},f={class:`inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30`},p=a({__name:`en_TermsAndConditionsView`,setup(a){let{t:p}=o();return(a,o)=>{let p=s,m=c;return e(),i(`div`,l,[r(`div`,u,[r(`div`,d,[r(`div`,f,[n(p,{name:`i-heroicons-document-text`,class:`w-8 h-8`})]),o[0]||=r(`h1`,{class:`text-3xl font-bold text-gray-900 dark:text-white`},`Terms and Conditions`,-1),o[1]||=r(`p`,{class:`mt-2 text-sm text-gray-600 dark:text-gray-400`},`Last updated: March 2026`,-1)]),n(m,{class:`shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50`},{footer:t(()=>[...o[2]||=[r(`div`,{class:`flex justify-center`},null,-1)]]),default:t(()=>[o[3]||=r(`div`,{class:`prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6`},[r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`1. Acceptance of Terms`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` By accessing and using TimeTracker, you accept and agree to be bound by the terms and provision of this agreement. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`2. Description of Service`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` TimeTracker is a time tracking application that allows users to track their working hours, manage projects, and generate reports. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`3. User Responsibilities`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},`You agree to:`),r(`ul`,{class:`list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4`},[r(`li`,null,`Provide accurate and complete information`),r(`li`,null,`Maintain the security of your account`),r(`li`,null,`Not share your login credentials with others`),r(`li`,null,`Use the service in compliance with applicable laws`)])]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`4. Privacy and Data Protection`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` We are committed to protecting your privacy. Your personal data will be processed in accordance with our Privacy Policy and applicable data protection laws. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`5. Intellectual Property`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` The TimeTracker service and all its contents, including but not limited to text, graphics, logos, and software, are the property of TimeTracker and are protected by intellectual property laws. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`6. Limitation of Liability`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` TimeTracker shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of or inability to use the service. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`7. Termination`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` We reserve the right to terminate or suspend your account at any time, without prior notice, for conduct that we believe violates these Terms and Conditions or is harmful to other users or the service. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`8. Changes to Terms`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` We reserve the right to modify these Terms and Conditions at any time. Your continued use of TimeTracker after any changes indicates your acceptance of the new terms. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`9. Contact Information`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` If you have any questions about these Terms and Conditions, please contact us at support@timetracker.com. `)])],-1)]),_:1})])])}}});export{p as default};
|
||||
15
assets/public/dist/assets/index-BqfKAJS4.js
vendored
Normal file
15
assets/public/dist/assets/index-BqfKAJS4.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/public/dist/assets/index-UnLOO1Sq.css
vendored
Normal file
2
assets/public/dist/assets/index-UnLOO1Sq.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/pl_PrivacyPolicyView-Bqyt2B2G.js
vendored
Normal file
1
assets/public/dist/assets/pl_PrivacyPolicyView-Bqyt2B2G.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/pl_TermsAndConditionsView-D4bXtPik.js
vendored
Normal file
1
assets/public/dist/assets/pl_TermsAndConditionsView-D4bXtPik.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,Q as t,_ as n,f as r,h as i,y as a}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{Q as o,t as s}from"./Icon-Chkiq2IE.js";import{t as c}from"./Card-DPC9xXwj.js";var l={class:`min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8`},u={class:`max-w-4xl mx-auto`},d={class:`text-center mb-12`},f={class:`inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30`},p=a({__name:`pl_TermsAndConditionsView`,setup(a){let{t:p}=o();return(a,o)=>{let p=s,m=c;return e(),i(`div`,l,[r(`div`,u,[r(`div`,d,[r(`div`,f,[n(p,{name:`i-heroicons-document-text`,class:`w-8 h-8`})]),o[0]||=r(`h1`,{class:`text-3xl font-bold text-gray-900 dark:text-white`},`Regulamin`,-1),o[1]||=r(`p`,{class:`mt-2 text-sm text-gray-600 dark:text-gray-400`},`Ostatnia aktualizacja: marzec 2026`,-1)]),n(m,{class:`shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50`},{footer:t(()=>[...o[2]||=[r(`div`,{class:`flex justify-center`},null,-1)]]),default:t(()=>[o[3]||=r(`div`,{class:`prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6`},[r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`1. Akceptacja Regulaminu`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Korzystając z aplikacji TimeTracker, akceptujesz i zgadzasz się na przestrzeganie warunków i postanowień niniejszej umowy. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`2. Opis Usługi`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` TimeTracker to aplikacja do śledzenia czasu pracy, która umożliwia użytkownikom śledzenie godzin pracy, zarządzanie projektami oraz generowanie raportów. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`3. Obowiązki Użytkownika`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},`Zgadzasz się na:`),r(`ul`,{class:`list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4`},[r(`li`,null,`Podawanie dokładnych i kompletnych informacji`),r(`li`,null,`Utrzymywanie bezpieczeństwa swojego konta`),r(`li`,null,`Nieudostępnianie danych logowania innym osobom`),r(`li`,null,`Korzystanie z usługi zgodnie z obowiązującymi przepisami prawa`)])]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`4. Prywatność i Ochrona Danych`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Jesteśmy zobowiązani do ochrony Twojej prywatności. Twoje dane osobowe będą przetwarzane zgodnie z naszą Polityką Prywatności oraz obowiązującymi przepisami o ochronie danych. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`5. Własność Intelektualna`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Usługa TimeTracker oraz wszystkie jej treści, w tym między innymi teksty, grafika, logo i oprogramowanie, stanowią własność TimeTracker i są chronione przepisami o własności intelektualnej. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`6. Ograniczenie Odpowiedzialności`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` TimeTracker nie ponosi odpowiedzialności za jakiekolwiek pośrednie, przypadkowe, specjalne, następcze lub karne szkody wynikające z korzystania lub niemożności korzystania z usługi. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`7. Rozwiązanie Umowy`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` zastrzegamy sobie prawo do rozwiązania lub zawieszenia Twojego konta w dowolnym momencie, bez wcześniejszego powiadomienia, za zachowanie, które narusza niniejszy Regulamin lub jest szkodliwe dla innych użytkowników lub usługi. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`8. Zmiany w Regulaminie`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` zastrzegamy sobie prawo do modyfikacji niniejszego Regulaminu w dowolnym momencie. Dalsze korzystanie z TimeTracker po wprowadzeniu zmian oznacza akceptację nowych warunków. `)]),r(`section`,null,[r(`h2`,{class:`text-xl font-semibold text-gray-900 dark:text-white`},`9. Informacje Kontaktowe`),r(`p`,{class:`text-gray-600 dark:text-gray-400`},` Jeśli masz jakiekolwiek pytania dotyczące niniejszego Regulaminu, skontaktuj się z nami pod adresem support@timetracker.com. `)])],-1)]),_:1})])])}}});export{p as default};
|
||||
5
assets/public/dist/assets/router-CoYWQDRi.js
vendored
Normal file
5
assets/public/dist/assets/router-CoYWQDRi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/router-Wd6OrXcf.js
vendored
Normal file
1
assets/public/dist/assets/router-Wd6OrXcf.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import"./useFetchJson-4WJQFaEO.js";import"./useForwardExpose-BgPOLLFN.js";import"./Icon-Chkiq2IE.js";import"./auth-hZSBdvj-.js";import{t as e}from"./router-CoYWQDRi.js";import"./Button-jwL-tYHc.js";import"./settings-BcOmX106.js";export{e as default};
|
||||
1
assets/public/dist/assets/settings-BcOmX106.js
vendored
Normal file
1
assets/public/dist/assets/settings-BcOmX106.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/public/dist/assets/useFetchJson-4WJQFaEO.js
vendored
Normal file
2
assets/public/dist/assets/useFetchJson-4WJQFaEO.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/auth-CdHmhksw.js","assets/auth-hZSBdvj-.js","assets/vue.runtime.esm-bundler-BM5WPBHd.js"])))=>i.map(i=>d[i]);
|
||||
var e=`modulepreload`,t=function(e){return`/`+e},n={};const r=function(r,i,a){let o=Promise.resolve();if(i&&i.length>0){let r=document.getElementsByTagName(`link`),s=document.querySelector(`meta[property=csp-nonce]`),c=s?.nonce||s?.getAttribute(`nonce`);function l(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}o=l(i.map(i=>{if(i=t(i,a),i in n)return;n[i]=!0;let o=i.endsWith(`.css`),s=o?`[rel="stylesheet"]`:``;if(a)for(let e=r.length-1;e>=0;e--){let t=r[e];if(t.href===i&&(!o||t.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${i}"]${s}`))return;let l=document.createElement(`link`);if(l.rel=o?`stylesheet`:e,o||(l.as=`script`),l.crossOrigin=``,l.href=i,c&&l.setAttribute(`nonce`,c),document.head.appendChild(l),o)return new Promise((e,t)=>{l.addEventListener(`load`,e),l.addEventListener(`error`,()=>t(Error(`Unable to preload CSS for ${i}`)))})}))}function s(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return o.then(e=>{for(let t of e||[])t.status===`rejected`&&s(t.reason);return r().catch(s)})};async function i(e,t){let n=a(``,e),i=new Headers(t?.headers);i.has(`Content-Type`)||i.set(`Content-Type`,`application/json`);let o={...t,headers:i,credentials:`same-origin`};try{let e=await fetch(n,o);if(!(e.headers.get(`content-type`)??``).includes(`application/json`))throw{message:`this is not proper json format`};let t=await e.json();if(e.status===401){let{useAuthStore:e}=await r(async()=>{let{useAuthStore:e}=await import(`./auth-CdHmhksw.js`);return{useAuthStore:e}},__vite__mapDeps([0,1,2])),i=e();if(await i.refreshAccessToken()){let e=await fetch(n,o);if(!(e.headers.get(`content-type`)??``).includes(`application/json`))throw{message:`this is not proper json format`};let t=await e.json();if(!e.ok)throw t;return t}throw i.logout(),t}if(!e.ok)throw t;return t}catch(e){throw e}}function a(...e){let t=e.filter(Boolean).join(`/`).replace(/\/{2,}/g,`/`);return t.startsWith(`/`)?t:`/${t}`}export{r as n,i as t};
|
||||
1
assets/public/dist/assets/useForwardExpose-BgPOLLFN.js
vendored
Normal file
1
assets/public/dist/assets/useForwardExpose-BgPOLLFN.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{J as e,b as t,ct as n,d as r,ut as i}from"./vue.runtime.esm-bundler-BM5WPBHd.js";import{t as a}from"./useFetchJson-4WJQFaEO.js";import{E as o,Z as s}from"./Icon-Chkiq2IE.js";const c=()=>{function e(e){let t=document.cookie?document.cookie.split(`; `):[];for(let n of t){let[t,...r]=n.split(`=`);if(t===e)return decodeURIComponent(r.join(`=`))}return null}function t(e,t,n){let r=`${e}=${encodeURIComponent(t)}`;if(n?.days){let e=new Date;e.setTime(e.getTime()+n.days*24*60*60*1e3),r+=`; expires=${e.toUTCString()}`}r+=`; path=${n?.path??`/`}`,n?.domain&&(r+=`; domain=${n.domain}`),n?.secure&&(r+=`; Secure`),n?.sameSite&&(r+=`; SameSite=${n.sameSite}`),document.cookie=r}function n(e,t=`/`,n){let r=`${e}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${t}`;n&&(r+=`; domain=${n}`),document.cookie=r}return{getCookie:e,setCookie:t,deleteCookie:n}},l=n([]),u=i();var d=i(),f=c();async function p(){try{let{items:e}=await a(`/api/v1/langs`);l.push(...e);let t=null,n=f.getCookie(`lang_id`);n&&(t=l.find(e=>e.id==parseInt(n))),d.value=e.find(e=>e.is_default==1),u.value=t??d.value}catch(e){console.error(`Failed to fetch languages:`,e)}}const m=s({legacy:!1,locale:`en`,lazy:!0,messages:{},messageResolver:(e,t)=>{let n=t.split(`.`).reduce((e,t)=>e?.[t],e);return n===``||n==null?null:n}}),h=m.global;var g=[];e(h.locale,async e=>{if(!g.includes(e)){let t=l.find(t=>t.iso_code==e);if(!t)return;g.push(e);let n=await a(`/api/v1/translations?lang_id=${t?.id}&scope=backoffice`);h.setLocaleMessage(e,n.items[t.id].backoffice)}},{});function _(){let e=t(),n=i(),a=r(()=>[`#text`,`#comment`].includes(n.value?.$el.nodeName)?n.value?.$el.nextElementSibling:o(n)),s=Object.assign({},e.exposed),c={};for(let t in e.props)Object.defineProperty(c,t,{enumerable:!0,configurable:!0,get:()=>e.props[t]});if(Object.keys(s).length>0)for(let e in s)Object.defineProperty(c,e,{enumerable:!0,configurable:!0,get:()=>s[e]});Object.defineProperty(c,`$el`,{enumerable:!0,configurable:!0,get:()=>e.vnode.el}),e.exposed=c;function l(t){if(n.value=t,t&&(Object.defineProperty(c,`$el`,{enumerable:!0,configurable:!0,get:()=>t instanceof Element?t:t.$el}),!(t instanceof Element)&&!Object.prototype.hasOwnProperty.call(t,`$el`))){let n=t.$.exposed,r=Object.assign({},c);for(let e in n)Object.defineProperty(r,e,{enumerable:!0,configurable:!0,get:()=>n[e]});e.exposed=r}}return{forwardRef:l,currentRef:n,currentElement:a}}export{p as a,u as i,h as n,l as o,m as r,c as s,_ as t};
|
||||
3
assets/public/dist/assets/usePortal-Zddbph8M.js
vendored
Normal file
3
assets/public/dist/assets/usePortal-Zddbph8M.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/useValidation-wBItIFut.js
vendored
Normal file
1
assets/public/dist/assets/useValidation-wBItIFut.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/public/dist/assets/utils-ZBSSwpFo.js
vendored
Normal file
1
assets/public/dist/assets/utils-ZBSSwpFo.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{h as e}from"./usePortal-Zddbph8M.js";var t=[`Enter`,` `],n=[`ArrowDown`,`PageUp`,`Home`],r=[`ArrowUp`,`PageDown`,`End`];[...n,...r],[...t],[...t];function i(e){return e?`open`:`closed`}function a(t){let n=e();for(let r of t)if(r===n||(r.focus(),e()!==n))return}export{i as n,a as t};
|
||||
7
assets/public/dist/assets/vue.runtime.esm-bundler-BM5WPBHd.js
vendored
Normal file
7
assets/public/dist/assets/vue.runtime.esm-bundler-BM5WPBHd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
assets/public/dist/favicon.ico
vendored
Normal file
BIN
assets/public/dist/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
38
assets/public/dist/index.html
vendored
Normal file
38
assets/public/dist/index.html
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimeTracker</title>
|
||||
<script>
|
||||
if (localStorage.getItem('vueuse-color-scheme') === 'dark' || (!('vueuse-color-scheme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
var theme = 'dark'
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
var theme = 'light'
|
||||
}
|
||||
var pageName = "default";
|
||||
globalThis.appInit = [];
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BqfKAJS4.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vue.runtime.esm-bundler-BM5WPBHd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useFetchJson-4WJQFaEO.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/Icon-Chkiq2IE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/Button-jwL-tYHc.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/HomeView-CdMOMcn8.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useForwardExpose-BgPOLLFN.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/usePortal-Zddbph8M.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/PopperArrow-CcUKYeE0.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/settings-BcOmX106.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/auth-hZSBdvj-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/Collection-BkGqWqUl.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/VisuallyHiddenInput-BH1aLUkb.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-CoYWQDRi.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-UnLOO1Sq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
assets/public/font/inter/inter-v20-latin-100.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-100.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-100italic.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-100italic.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-200.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-200.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-200italic.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-200italic.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-300.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-300.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-300italic.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-300italic.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-500.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-500.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-500italic.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-500italic.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-600.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-600.woff2
Normal file
Binary file not shown.
BIN
assets/public/font/inter/inter-v20-latin-600italic.woff2
Normal file
BIN
assets/public/font/inter/inter-v20-latin-600italic.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user