initial commit. Cloned timetracker repository

This commit is contained in:
Daniel Goc
2026-03-10 09:02:57 +01:00
commit f2952bcef0
189 changed files with 21334 additions and 0 deletions

85
Taskfile.yml Normal file
View 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
View File

@@ -0,0 +1,8 @@
package api
import (
_ "embed"
)
//go:embed openapi.json
var ApenapiJson string

944
app/api/openapi.json Normal file
View 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
View 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
View 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
View 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
// }

View 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)
}

View 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)
}

View 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()
}

View 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()
}
}

View 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
View 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(),
})
}

View 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")
}

View 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")
})
}

View 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(),
})
})
}

View 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,
})
})
}

View 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))
}

View 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)
}

View 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)))
}
}

View 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
View 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
View 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
View 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
View 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
View 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"`
}

View 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
}

View 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
}

View 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()
}

View 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(),
}
}

View 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
}

View 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>&copy; 2024 Gitea Manager. All rights reserved.</p>
</div>
</div>
</div>
}
}

View 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>&copy; 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

View 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>&copy; 2024 Gitea Manager. { i18n.T___(data.LangID, "email.email_footer") } </p>
</div>
</div>
</div>
}
}

View 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>&copy; 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

View 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>&copy; 2024 Gitea Manager. { i18n.T___(data.LangID, "email.email_footer")}</p>
</div>
</div>
</div>
}
}

View 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>&copy; 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

View 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>
}

View 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

View 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
View 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))
}
}

View 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)
}
}

View 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")
}
}

View File

@@ -0,0 +1,6 @@
package nullable
//go:fix inline
func GetNil[T any](in T) *T {
return new(in)
}

View 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)}
}

View 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"
)

View 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,
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

View 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};

View File

@@ -0,0 +1 @@
import{t as e}from"./HomeView-CdMOMcn8.js";export{e as default};

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

View 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};

View 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};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import"./useFetchJson-4WJQFaEO.js";import{t as e}from"./auth-hZSBdvj-.js";export{e as useAuthStore};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

View 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};

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

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
View 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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More