initial commit. Cloned timetracker repository
This commit is contained in:
8
app/api/embed.go
Normal file
8
app/api/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed openapi.json
|
||||
var ApenapiJson string
|
||||
944
app/api/openapi.json
Normal file
944
app/api/openapi.json
Normal file
@@ -0,0 +1,944 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "timeTracker API",
|
||||
"description": "Authentication and user management API",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "API Support",
|
||||
"email": "support@example.com"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:3000",
|
||||
"description": "Development server"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "Health",
|
||||
"description": "Health check endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Auth",
|
||||
"description": "Authentication endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Languages",
|
||||
"description": "Language and translation endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Protected",
|
||||
"description": "Protected routes requiring authentication"
|
||||
},
|
||||
{
|
||||
"name": "Admin",
|
||||
"description": "Admin-only endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"description": "Application settings and configuration endpoints"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": ["Health"],
|
||||
"summary": "Health check",
|
||||
"description": "Returns the health status of the application",
|
||||
"operationId": "getHealth",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"app": {
|
||||
"type": "string",
|
||||
"example": "timeTracker"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/langs": {
|
||||
"get": {
|
||||
"tags": ["Languages"],
|
||||
"summary": "Get active languages",
|
||||
"description": "Returns a list of all active languages",
|
||||
"operationId": "getLanguages",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Language"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/translations": {
|
||||
"get": {
|
||||
"tags": ["Languages"],
|
||||
"summary": "Get translations",
|
||||
"description": "Returns translations from cache. Supports filtering by lang_id, scope, and components.",
|
||||
"operationId": "getTranslations",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "lang_id",
|
||||
"in": "query",
|
||||
"description": "Filter by language ID",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"in": "query",
|
||||
"description": "Filter by scope (e.g., 'be', 'frontend')",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "components",
|
||||
"in": "query",
|
||||
"description": "Filter by component name",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success"
|
||||
},
|
||||
"translations": {
|
||||
"type": "object",
|
||||
"description": "Translation data keyed by language ID, scope, component, and key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request parameters",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/translations/reload": {
|
||||
"get": {
|
||||
"tags": ["Languages"],
|
||||
"summary": "Reload translations",
|
||||
"description": "Reloads translations from the database into the cache",
|
||||
"operationId": "reloadTranslations",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Translations reloaded successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Translations reloaded successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to reload translations",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "User login",
|
||||
"description": "Authenticate a user with email and password",
|
||||
"operationId": "login",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Login successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Set-Cookie": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "HTTP-only cookies containing access and refresh tokens"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid credentials",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Account inactive or email not verified",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/register": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "User registration",
|
||||
"description": "Register a new user account",
|
||||
"operationId": "register",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegisterRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Registration successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "registration successful, please verify your email"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request or email already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/complete-registration": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Complete registration",
|
||||
"description": "Complete registration after email verification",
|
||||
"operationId": "completeRegistration",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CompleteRegistrationRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Registration completed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/forgot-password": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Request password reset",
|
||||
"description": "Request a password reset email",
|
||||
"operationId": "forgotPassword",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["email"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset email sent if account exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "if an account with that email exists, a password reset link has been sent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/reset-password": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Reset password",
|
||||
"description": "Reset password using reset token",
|
||||
"operationId": "resetPassword",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "password reset successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "User logout",
|
||||
"description": "Clear authentication cookies",
|
||||
"operationId": "logout",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Logout successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "logged out successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Refresh access token",
|
||||
"description": "Get a new access token using refresh token",
|
||||
"operationId": "refreshToken",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"description": "Refresh token from login response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Token refreshed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Refresh token required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid or expired refresh token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/protected/dashboard": {
|
||||
"get": {
|
||||
"tags": ["Protected"],
|
||||
"summary": "Get dashboard data",
|
||||
"description": "Protected route requiring authentication",
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Dashboard data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/UserSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users": {
|
||||
"get": {
|
||||
"tags": ["Admin"],
|
||||
"summary": "Get all users",
|
||||
"description": "Admin-only endpoint for user management",
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of users",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Admin access required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/settings": {
|
||||
"get": {
|
||||
"tags": ["Settings"],
|
||||
"summary": "Get application settings",
|
||||
"description": "Returns public application settings and configuration",
|
||||
"operationId": "getSettings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Settings retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"LoginRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "User's password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password", "confirm_password"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "User's password (min 8 chars, uppercase, lowercase, digit)"
|
||||
},
|
||||
"confirm_password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "Password confirmation"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string",
|
||||
"description": "User's first name"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string",
|
||||
"description": "User's last name"
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"description": "User's preferred language (e.g., 'en', 'pl', 'cs')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CompleteRegistrationRequest": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Email verification token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"required": ["token", "password"],
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Password reset token"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"description": "New password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string",
|
||||
"description": "JWT access token"
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"description": "JWT refresh token"
|
||||
},
|
||||
"token_type": {
|
||||
"type": "string",
|
||||
"example": "Bearer"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer",
|
||||
"description": "Token expiration in seconds"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/UserSession"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"description": "User ID"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["user", "admin"],
|
||||
"description": "User role"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Language": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"description": "Language ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Language name"
|
||||
},
|
||||
"iso_code": {
|
||||
"type": "string",
|
||||
"description": "ISO 639-1 code (e.g., 'en', 'pl')"
|
||||
},
|
||||
"lang_code": {
|
||||
"type": "string",
|
||||
"description": "Full language code (e.g., 'en-US', 'pl-PL')"
|
||||
},
|
||||
"date_format": {
|
||||
"type": "string",
|
||||
"description": "Date format string"
|
||||
},
|
||||
"date_format_short": {
|
||||
"type": "string",
|
||||
"description": "Short date format string"
|
||||
},
|
||||
"rtl": {
|
||||
"type": "boolean",
|
||||
"description": "Right-to-left language"
|
||||
},
|
||||
"is_default": {
|
||||
"type": "boolean",
|
||||
"description": "Is default language"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "Is active"
|
||||
},
|
||||
"flag": {
|
||||
"type": "string",
|
||||
"description": "Flag emoji or code"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SettingsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": {
|
||||
"$ref": "#/components/schemas/AppSettings"
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/components/schemas/ServerSettings"
|
||||
},
|
||||
"auth": {
|
||||
"$ref": "#/components/schemas/AuthSettings"
|
||||
},
|
||||
"features": {
|
||||
"$ref": "#/components/schemas/FeatureFlags"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/components/schemas/VersionInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Application name"
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
"description": "Application environment (e.g., 'development', 'production')"
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"description": "Base URL of the application"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServerSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Server port"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Server host"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jwt_expiration": {
|
||||
"type": "integer",
|
||||
"description": "JWT token expiration in seconds"
|
||||
},
|
||||
"refresh_expiration": {
|
||||
"type": "integer",
|
||||
"description": "Refresh token expiration in seconds"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FeatureFlags": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether email functionality is enabled"
|
||||
},
|
||||
"oauth_google": {
|
||||
"type": "boolean",
|
||||
"description": "Whether Google OAuth is enabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VersionInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Application version"
|
||||
},
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"description": "Git commit hash"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"description": "Build date"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT token obtained from login response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/cmd/main.go
Normal file
41
app/cmd/main.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for version subcommand
|
||||
versionFlag := flag.Bool("version", false, "Show version information")
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
log.Println(version.String())
|
||||
return
|
||||
}
|
||||
|
||||
// Create and setup the server
|
||||
server := web.New()
|
||||
|
||||
// Configure routes
|
||||
if err := server.Setup(); err != nil {
|
||||
log.Fatalf("Failed to setup server: %v", err)
|
||||
}
|
||||
|
||||
// Load translations on startup
|
||||
if err := langs.LangSrv.LoadTranslations(); err != nil {
|
||||
log.Printf("Warning: Failed to load translations on startup: %v", err)
|
||||
} else {
|
||||
log.Println("Translations loaded successfully on startup")
|
||||
}
|
||||
|
||||
// Start the server
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
281
app/config/config.go
Normal file
281
app/config/config.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Auth AuthConfig
|
||||
OAuth OAuthConfig
|
||||
App AppConfig
|
||||
Email EmailConfig
|
||||
I18n I18n
|
||||
Pdf PdfPrinter
|
||||
}
|
||||
|
||||
type I18n struct {
|
||||
Langs []string `env:"I18N_LANGS,en,pl"`
|
||||
}
|
||||
type ServerConfig struct {
|
||||
Port int `env:"SERVER_PORT,3000"`
|
||||
Host string `env:"SERVER_HOST,0.0.0.0"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `env:"DB_HOST,localhost"`
|
||||
Port int `env:"DB_PORT"`
|
||||
User string `env:"DB_USER"`
|
||||
Password string `env:"DB_PASSWORD"`
|
||||
Name string `env:"DB_NAME"`
|
||||
SSLMode string `env:",disable"`
|
||||
MaxIdleConns int `env:",10"`
|
||||
MaxOpenConns int `env:",100"`
|
||||
ConnMaxLifetime time.Duration `env:",1h"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `env:"AUTH_JWT_SECRET"`
|
||||
JWTExpiration int `env:"AUTH_JWT_EXPIRATION"`
|
||||
RefreshExpiration int `env:"AUTH_REFRESH_EXPIRATION"`
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
Google GoogleOAuthConfig
|
||||
}
|
||||
|
||||
type GoogleOAuthConfig struct {
|
||||
ClientID string `env:"OAUTH_GOOGLE_CLIENT_ID"`
|
||||
ClientSecret string `env:"OAUTH_GOOGLE_CLIENT_SECRET"`
|
||||
RedirectURL string `env:"OAUTH_GOOGLE_REDIRECT_URL"`
|
||||
Scopes []string `env:""`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Name string `env:"APP_NAME,Gitea Manager"`
|
||||
Version string `env:"APP_VERSION,1.0.0"`
|
||||
Environment string `env:"APP_ENVIRONMENT,development"`
|
||||
BaseURL string `env:"APP_BASE_URL,http://localhost:5173"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
SMTPHost string `env:"EMAIL_SMTP_HOST,localhost"`
|
||||
SMTPPort int `env:"EMAIL_SMTP_PORT,587"`
|
||||
SMTPUser string `env:"EMAIL_SMTP_USER"`
|
||||
SMTPPassword string `env:"EMAIL_SMTP_PASSWORD"`
|
||||
FromEmail string `env:"EMAIL_FROM,noreply@example.com"`
|
||||
FromName string `env:"EMAIL_FROM_NAME,Gitea Manager"`
|
||||
AdminEmail string `env:"EMAIL_ADMIN,admin@example.com"`
|
||||
Enabled bool `env:"EMAIL_ENABLED,false"`
|
||||
}
|
||||
|
||||
type PdfPrinter struct {
|
||||
ServerUrl string `env:"PDF_SERVER_URL,http://localhost:8000"`
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
|
||||
func init() {
|
||||
if cfg == nil {
|
||||
cfg = load()
|
||||
}
|
||||
}
|
||||
|
||||
func Get() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GetDSN returns the database connection string
|
||||
func (c *DatabaseConfig) GetDSN() string {
|
||||
return fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.User,
|
||||
c.Password,
|
||||
c.Name,
|
||||
c.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
func load() *Config {
|
||||
|
||||
cfg := &Config{}
|
||||
|
||||
err := godotenv.Load(".env")
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env: %s", err.Error(), "")
|
||||
}
|
||||
err = loadEnv(&cfg.Database)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for database : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Server)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for server : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Auth)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for auth : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.OAuth.Google)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for outh google : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.App)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for app : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Email)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for email : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.I18n)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for email : ", err.Error(), "")
|
||||
}
|
||||
|
||||
err = loadEnv(&cfg.Pdf)
|
||||
if err != nil {
|
||||
slog.Error("not possible to load env variables for email : ", err.Error(), "")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadEnv(dst any) error {
|
||||
v := reflect.ValueOf(dst)
|
||||
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("dst must be pointer to struct")
|
||||
}
|
||||
|
||||
return loadStruct(v.Elem())
|
||||
}
|
||||
|
||||
func loadStruct(v reflect.Value) error {
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := t.Field(i)
|
||||
|
||||
if !field.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
// nested struct
|
||||
if field.Kind() == reflect.Struct && field.Type() != reflect.TypeOf(time.Duration(0)) {
|
||||
if err := loadStruct(field); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
tag := fieldType.Tag.Get("env")
|
||||
key, def := parseEnvTag(tag)
|
||||
|
||||
if key == "" {
|
||||
key = fieldType.Name
|
||||
}
|
||||
|
||||
val, ok := os.LookupEnv(key)
|
||||
|
||||
// fallback to default
|
||||
if !ok && def != nil {
|
||||
val = *def
|
||||
ok = true
|
||||
}
|
||||
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := setValue(field, val, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setValue(field reflect.Value, val string, key string) error {
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
field.SetString(val)
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
// time.Duration
|
||||
if field.Type() == reflect.TypeOf(time.Duration(0)) {
|
||||
d, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("env %s: %w", key, err)
|
||||
}
|
||||
field.SetInt(int64(d))
|
||||
return nil
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("env %s: %w", key, err)
|
||||
}
|
||||
field.SetInt(int64(i))
|
||||
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("env %s: %w", key, err)
|
||||
}
|
||||
field.SetBool(b)
|
||||
|
||||
case reflect.Slice:
|
||||
if field.Type().Elem().Kind() == reflect.String {
|
||||
// Split by comma and trim whitespace
|
||||
parts := strings.Split(val, ",")
|
||||
slice := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
slice = append(slice, p)
|
||||
}
|
||||
}
|
||||
field.Set(reflect.ValueOf(slice))
|
||||
} else {
|
||||
return fmt.Errorf("unsupported slice type %s for env %s", field.Type().Elem().Kind(), key)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported type %s for env %s", field.Kind(), key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEnvTag(tag string) (key string, def *string) {
|
||||
if tag == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(tag, ",", 2)
|
||||
key = parts[0]
|
||||
|
||||
if len(parts) == 2 {
|
||||
return key, &parts[1] // Returns "en,pl,de" for slices - setValue handles the split
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
109
app/db/postgres.go
Normal file
109
app/db/postgres.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func init() {
|
||||
if DB == nil {
|
||||
dbconn, err := newPostgresDB(&config.Get().Database)
|
||||
if err != nil {
|
||||
slog.Error("⚠️ No connection to database was possible to establish", "error", err.Error())
|
||||
}
|
||||
DB = dbconn
|
||||
}
|
||||
}
|
||||
|
||||
func Get() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// newPostgresDB creates a new PostgreSQL database connection
|
||||
func newPostgresDB(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
dsn := cfg.GetDSN()
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Error),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
|
||||
// Connection pool settings
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
|
||||
log.Println("✓ Database connection established successfully")
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// // RunMigrations runs all database migrations
|
||||
// func RunMigrations() error {
|
||||
// if DB == nil {
|
||||
// return fmt.Errorf("database connection not established")
|
||||
// }
|
||||
|
||||
// log.Println("Running database migrations...")
|
||||
|
||||
// // Add your models here for AutoMigrate
|
||||
// // Example: err := db.AutoMigrate(&model.Customer{})
|
||||
|
||||
// err := DB.AutoMigrate(&model.Customer{})
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to run migrations: %w", err)
|
||||
// }
|
||||
|
||||
// log.Println("✓ Database migrations completed successfully")
|
||||
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// // SeedAdminUser creates a default admin user if one doesn't exist
|
||||
// // Call this function with admin credentials after migrations
|
||||
// func SeedAdminUser(adminEmail, adminPassword string) error {
|
||||
// log.Println("✓ Admin seeding ready - implement with your User model")
|
||||
|
||||
// // Example implementation when you have a User model:
|
||||
// // var count int64
|
||||
// // db.Model(&model.User{}).Where("role = ?", "admin").Count(&count)
|
||||
// // if count > 0 {
|
||||
// // log.Println("✓ Admin user already exists")
|
||||
// // return nil
|
||||
// // }
|
||||
// // hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||
// // if err != nil {
|
||||
// // return fmt.Errorf("failed to hash password: %w", err)
|
||||
// // }
|
||||
// // admin := model.User{
|
||||
// // Email: adminEmail,
|
||||
// // Password: string(hashedPassword),
|
||||
// // Role: "admin",
|
||||
// // IsActive: true,
|
||||
// // }
|
||||
// // if err := db.Create(&admin).Error; err != nil {
|
||||
// // return err
|
||||
// // }
|
||||
// // log.Printf("✓ Created admin user: %s", adminEmail)
|
||||
|
||||
// // Suppress unused variable warning
|
||||
// _, _ = bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||
|
||||
// return nil
|
||||
// }
|
||||
11
app/delivery/handler/auth.go
Normal file
11
app/delivery/handler/auth.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandlerRoutes registers all auth routes
|
||||
func AuthHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
return public.AuthHandlerRoutes(r)
|
||||
}
|
||||
11
app/delivery/handler/repo.go
Normal file
11
app/delivery/handler/repo.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandlerRoutes registers all auth routes
|
||||
func RepoHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
return public.RepoHandlerRoutes(r)
|
||||
}
|
||||
118
app/delivery/middleware/auth.go
Normal file
118
app/delivery/middleware/auth.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/authService"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthMiddleware creates authentication middleware
|
||||
func AuthMiddleware() fiber.Handler {
|
||||
authService := authService.NewAuthService()
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
// Get token from Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// Try to get from cookie
|
||||
authHeader = c.Cookies("access_token")
|
||||
if authHeader == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "authorization token required",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Extract token from "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid authorization header format",
|
||||
})
|
||||
}
|
||||
authHeader = parts[1]
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := authService.ValidateToken(authHeader)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
user, err := authService.GetUserByID(claims.UserID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "user not found",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "user account is inactive",
|
||||
})
|
||||
}
|
||||
|
||||
// Set user in context
|
||||
c.Locals("user", user.ToSession())
|
||||
c.Locals("userID", user.ID)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin creates admin-only middleware
|
||||
func RequireAdmin() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
user := c.Locals("user")
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
userSession, ok := user.(*model.UserSession)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "invalid user session",
|
||||
})
|
||||
}
|
||||
|
||||
if userSession.Role != model.RoleAdmin {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "admin access required",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID extracts user ID from context
|
||||
func GetUserID(c fiber.Ctx) uint {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
// GetUser extracts user from context
|
||||
func GetUser(c fiber.Ctx) *model.UserSession {
|
||||
user, ok := c.Locals("user").(*model.UserSession)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// GetConfig returns the app config
|
||||
func GetConfig() *config.Config {
|
||||
return config.Get()
|
||||
}
|
||||
18
app/delivery/middleware/cors.go
Normal file
18
app/delivery/middleware/cors.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
// CORSMiddleware creates CORS middleware
|
||||
func CORSMiddleware() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
c.Set("Access-Control-Allow-Origin", "*")
|
||||
c.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if c.Method() == "OPTIONS" {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
114
app/delivery/middleware/language.go
Normal file
114
app/delivery/middleware/language.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// LanguageMiddleware discovers client's language and stores it in context
|
||||
// Priority: Query param > Cookie > Accept-Language header > Default language
|
||||
func LanguageMiddleware() fiber.Handler {
|
||||
langService := langs.LangSrv
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
var langID uint
|
||||
|
||||
// 1. Check query parameter
|
||||
langIDStr := c.Query("lang_id", "")
|
||||
if langIDStr != "" {
|
||||
if id, err := strconv.ParseUint(langIDStr, 10, 32); err == nil {
|
||||
langID = uint(id)
|
||||
if langID > 0 {
|
||||
lang, err := langService.GetLanguageById(langID)
|
||||
if err == nil {
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", lang)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check cookie
|
||||
cookieLang := c.Cookies("lang_id", "")
|
||||
if cookieLang != "" {
|
||||
if id, err := strconv.ParseUint(cookieLang, 10, 32); err == nil {
|
||||
langID = uint(id)
|
||||
if langID > 0 {
|
||||
lang, err := langService.GetLanguageById(langID)
|
||||
if err == nil {
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", lang)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check Accept-Language header
|
||||
acceptLang := c.Get("Accept-Language", "")
|
||||
if acceptLang != "" {
|
||||
// Parse the Accept-Language header (e.g., "en-US,en;q=0.9,pl;q=0.8")
|
||||
isoCode := parseAcceptLanguage(acceptLang)
|
||||
if isoCode != "" {
|
||||
lang, err := langService.GetLanguageByISOCode(isoCode)
|
||||
if err == nil && lang != nil {
|
||||
langID = uint(lang.ID)
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", lang)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall back to default language
|
||||
defaultLang, err := langService.GetDefaultLanguage()
|
||||
if err == nil && defaultLang != nil {
|
||||
langID = uint(defaultLang.ID)
|
||||
c.Locals("langID", langID)
|
||||
c.Locals("lang", defaultLang)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// parseAcceptLanguage extracts the primary language ISO code from Accept-Language header
|
||||
func parseAcceptLanguage(header string) string {
|
||||
// Split by comma
|
||||
parts := strings.Split(header, ",")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get the first part (highest priority)
|
||||
first := strings.TrimSpace(parts[0])
|
||||
if first == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove any quality value (e.g., ";q=0.9")
|
||||
if idx := strings.Index(first, ";"); idx != -1 {
|
||||
first = strings.TrimSpace(first[:idx])
|
||||
}
|
||||
|
||||
// Handle cases like "en-US" or "en"
|
||||
// Return the primary language code (first part before dash)
|
||||
if idx := strings.Index(first, "-"); idx != -1 {
|
||||
return strings.ToLower(first[:idx])
|
||||
}
|
||||
|
||||
return strings.ToLower(first)
|
||||
}
|
||||
|
||||
// GetLanguageID extracts language ID from context
|
||||
func GetLanguageID(c fiber.Ctx) uint {
|
||||
langID, ok := c.Locals("langID").(uint)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return langID
|
||||
}
|
||||
177
app/delivery/web/init.go
Normal file
177
app/delivery/web/init.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/handler"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/middleware"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/web/public"
|
||||
|
||||
// "github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
// "github.com/gofiber/fiber/v3/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
)
|
||||
|
||||
// Server represents the web server
|
||||
type Server struct {
|
||||
app *fiber.App
|
||||
cfg *config.Config
|
||||
api fiber.Router
|
||||
}
|
||||
|
||||
// App returns the fiber app
|
||||
func (s *Server) App() *fiber.App {
|
||||
return s.app
|
||||
}
|
||||
|
||||
// Cfg returns the config
|
||||
func (s *Server) Cfg() *config.Config {
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// New creates a new server instance
|
||||
func New() *Server {
|
||||
return &Server{
|
||||
app: fiber.New(fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
}),
|
||||
cfg: config.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// Setup configures the server with routes and middleware
|
||||
func (s *Server) Setup() error {
|
||||
// Global middleware
|
||||
s.app.Use(recover.New())
|
||||
s.app.Use(logger.New())
|
||||
|
||||
// CORS middleware
|
||||
s.app.Use(middleware.CORSMiddleware())
|
||||
|
||||
// Language middleware - discovers client's language and stores in context
|
||||
s.app.Use(middleware.LanguageMiddleware())
|
||||
|
||||
// initialize healthcheck
|
||||
public.InitHealth(s.App(), s.Cfg())
|
||||
|
||||
// serve favicon
|
||||
public.Favicon(s.app, s.cfg)
|
||||
|
||||
// API routes
|
||||
s.api = s.app.Group("/api/v1")
|
||||
|
||||
// initialize swagger endpoints
|
||||
public.InitSwagger(s.App())
|
||||
|
||||
// Auth routes (public)
|
||||
auth := s.api.Group("/auth")
|
||||
handler.AuthHandlerRoutes(auth)
|
||||
|
||||
// Repo routes (public)
|
||||
repo := s.api.Group("/repo")
|
||||
repo.Use(middleware.AuthMiddleware())
|
||||
handler.RepoHandlerRoutes(repo)
|
||||
|
||||
// Protected routes example
|
||||
protected := s.api.Group("/restricted")
|
||||
protected.Use(middleware.AuthMiddleware())
|
||||
protected.Get("/dashboard", func(c fiber.Ctx) error {
|
||||
user := middleware.GetUser(c)
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Welcome to the protected area",
|
||||
"user": user,
|
||||
})
|
||||
})
|
||||
|
||||
// Admin routes example
|
||||
admin := s.api.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware())
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.Get("/users", func(c fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Admin area - user management",
|
||||
})
|
||||
})
|
||||
|
||||
public.NewLangHandler().InitLanguage(s.api, s.cfg)
|
||||
|
||||
// Settings endpoint
|
||||
public.NewSettingsHandler().InitSettings(s.api, s.cfg)
|
||||
|
||||
// keep this at the end because its wilderange
|
||||
public.InitBo(s.App())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the server
|
||||
func (s *Server) Run() error {
|
||||
// Run database migrations
|
||||
// if err := db.RunMigrations(); err != nil {
|
||||
// log.Printf("⚠️ Database migrations failed: %v", err)
|
||||
// } else {
|
||||
// log.Println("✓ Database migrations completed")
|
||||
// }
|
||||
|
||||
// // Seed admin user
|
||||
// if err := db.SeedAdminUser("admin@example.com", "admin123"); err != nil {
|
||||
// log.Printf("⚠️ Admin user seeding failed: %v", err)
|
||||
// }
|
||||
|
||||
addr := s.cfg.Server.Host + ":" + strconv.Itoa(s.cfg.Server.Port)
|
||||
log.Printf("Starting server on %s", addr)
|
||||
log.Printf("Swagger UI available at http://%s/swagger/index.html", addr)
|
||||
log.Printf("OpenAPI JSON available at http://%s/openapi.json", addr)
|
||||
|
||||
go func() {
|
||||
if err := s.app.Listen(":3000"); err != nil {
|
||||
log.Println("Server stopped:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
signal.Notify(
|
||||
quit,
|
||||
syscall.SIGINT, // Ctrl+C
|
||||
syscall.SIGTERM, // docker stop
|
||||
)
|
||||
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// graceful shutdown with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.app.ShutdownWithContext(ctx); err != nil {
|
||||
log.Fatal("Shutdown error:", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited cleanly")
|
||||
return nil
|
||||
}
|
||||
|
||||
// customErrorHandler handles errors
|
||||
func customErrorHandler(c fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
416
app/delivery/web/public/auth.go
Normal file
416
app/delivery/web/public/auth.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/delivery/middleware"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/authService"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication endpoints
|
||||
type AuthHandler struct {
|
||||
authService *authService.AuthService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new AuthHandler instance
|
||||
func NewAuthHandler() *AuthHandler {
|
||||
authService := authService.NewAuthService()
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
config: config.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// AuthHandlerRoutes registers all auth routes
|
||||
func AuthHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
handler := NewAuthHandler()
|
||||
|
||||
r.Post("/login", handler.Login)
|
||||
r.Post("/register", handler.Register)
|
||||
r.Post("/complete-registration", handler.CompleteRegistration)
|
||||
r.Post("/forgot-password", handler.ForgotPassword)
|
||||
r.Post("/reset-password", handler.ResetPassword)
|
||||
r.Post("/logout", handler.Logout)
|
||||
r.Post("/refresh", handler.RefreshToken)
|
||||
|
||||
// Google OAuth2
|
||||
r.Get("/google", handler.GoogleLogin)
|
||||
r.Get("/google/callback", handler.GoogleCallback)
|
||||
|
||||
authProtected := r.Group("", middleware.AuthMiddleware())
|
||||
authProtected.Get("/me", handler.Me)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c fiber.Ctx) error {
|
||||
var req model.LoginRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Email == "" || req.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
response, rawRefreshToken, err := h.authService.Login(&req)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set cookies for web-based authentication
|
||||
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// setAuthCookies sets the access token (HTTPOnly) and refresh token (HTTPOnly) cookies,
|
||||
// plus a non-HTTPOnly is_authenticated flag cookie for frontend state detection.
|
||||
func (h *AuthHandler) setAuthCookies(c fiber.Ctx, accessToken, rawRefreshToken string) {
|
||||
isProduction := h.config.App.Environment == "production"
|
||||
|
||||
// HTTPOnly access token cookie — not readable by JS, protects against XSS
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "access_token",
|
||||
Value: accessToken,
|
||||
Expires: time.Now().Add(time.Duration(h.config.Auth.JWTExpiration) * time.Second),
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
|
||||
// HTTPOnly refresh token cookie — opaque, stored as hash in DB
|
||||
if rawRefreshToken != "" {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: rawRefreshToken,
|
||||
Expires: time.Now().Add(time.Duration(h.config.Auth.RefreshExpiration) * time.Second),
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// Non-HTTPOnly flag cookie — readable by JS to detect auth state.
|
||||
// Contains no sensitive data; actual auth is enforced by the HTTPOnly access_token cookie.
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "is_authenticated",
|
||||
Value: "1",
|
||||
Expires: time.Now().Add(time.Duration(h.config.Auth.JWTExpiration) * time.Second),
|
||||
HTTPOnly: false,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// clearAuthCookies expires all auth-related cookies
|
||||
func (h *AuthHandler) clearAuthCookies(c fiber.Ctx) {
|
||||
isProduction := h.config.App.Environment == "production"
|
||||
past := time.Now().Add(-time.Hour)
|
||||
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "access_token",
|
||||
Value: "",
|
||||
Expires: past,
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: "",
|
||||
Expires: past,
|
||||
HTTPOnly: true,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "is_authenticated",
|
||||
Value: "",
|
||||
Expires: past,
|
||||
HTTPOnly: false,
|
||||
Secure: isProduction,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
// ForgotPassword handles password reset request
|
||||
func (h *AuthHandler) ForgotPassword(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Email string `json:"email" form:"email"`
|
||||
}
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if req.Email == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrEmailRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Request password reset - always return success to prevent email enumeration
|
||||
err := h.authService.RequestPasswordReset(req.Email)
|
||||
if err != nil {
|
||||
log.Printf("Password reset request error: %v", err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_if_account_exists"),
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword handles password reset completion
|
||||
func (h *AuthHandler) ResetPassword(c fiber.Ctx) error {
|
||||
var req model.ResetPasswordRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Token == "" || req.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrTokenPasswordRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Reset password (also revokes all refresh tokens for the user)
|
||||
err := h.authService.ResetPassword(req.Token, req.Password)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_password_reset_successfully"),
|
||||
})
|
||||
}
|
||||
|
||||
// Logout handles user logout — revokes the refresh token from DB and clears all cookies
|
||||
func (h *AuthHandler) Logout(c fiber.Ctx) error {
|
||||
// Revoke the refresh token from the database
|
||||
rawRefreshToken := c.Cookies("refresh_token")
|
||||
if rawRefreshToken != "" {
|
||||
h.authService.RevokeRefreshToken(rawRefreshToken)
|
||||
}
|
||||
|
||||
// Clear all auth cookies
|
||||
h.clearAuthCookies(c)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_logged_out_successfully"),
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshToken handles token refresh — validates opaque refresh token, rotates it, issues new access token
|
||||
func (h *AuthHandler) RefreshToken(c fiber.Ctx) error {
|
||||
// Get refresh token from HTTPOnly cookie (preferred) or request body (fallback for API clients)
|
||||
rawRefreshToken := c.Cookies("refresh_token")
|
||||
if rawRefreshToken == "" {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := c.Bind().Body(&body); err == nil {
|
||||
rawRefreshToken = body.RefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
if rawRefreshToken == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrRefreshTokenRequired),
|
||||
})
|
||||
}
|
||||
|
||||
response, newRawRefreshToken, err := h.authService.RefreshToken(rawRefreshToken)
|
||||
if err != nil {
|
||||
// If refresh token is invalid/expired, clear cookies
|
||||
h.clearAuthCookies(c)
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set new cookies (rotated refresh token + new access token)
|
||||
h.setAuthCookies(c, response.AccessToken, newRawRefreshToken)
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// Me returns the current user info
|
||||
func (h *AuthHandler) Me(c fiber.Ctx) error {
|
||||
user := c.Locals("user")
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrNotAuthenticated),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// Register handles user registration
|
||||
func (h *AuthHandler) Register(c fiber.Ctx) error {
|
||||
var req model.RegisterRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.FirstName == "" || req.LastName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrFirstLastNameRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Email == "" || req.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Attempt registration
|
||||
err := h.authService.Register(&req)
|
||||
if err != nil {
|
||||
log.Printf("Register error: %v", err)
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"message": i18n.T_(c, "auth.auth_registration_successful"),
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteRegistration handles completion of registration with password
|
||||
func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error {
|
||||
var req model.CompleteRegistrationRequest
|
||||
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrTokenRequired),
|
||||
})
|
||||
}
|
||||
|
||||
// Attempt to complete registration
|
||||
response, rawRefreshToken, err := h.authService.CompleteRegistration(&req)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set cookies for web-based authentication
|
||||
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
// GoogleLogin redirects the user to Google's OAuth2 consent page
|
||||
func (h *AuthHandler) GoogleLogin(c fiber.Ctx) error {
|
||||
// Generate a random state token and store it in a short-lived cookie
|
||||
state, err := h.authService.GenerateOAuthState()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": i18n.T_(c, "error.err_internal_server_error"),
|
||||
})
|
||||
}
|
||||
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "oauth_state",
|
||||
Value: state,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
HTTPOnly: true,
|
||||
Secure: h.config.App.Environment == "production",
|
||||
SameSite: "Lax",
|
||||
})
|
||||
|
||||
url := h.authService.GetGoogleAuthURL(state)
|
||||
return c.Redirect().To(url)
|
||||
}
|
||||
|
||||
// GoogleCallback handles the OAuth2 callback from Google
|
||||
func (h *AuthHandler) GoogleCallback(c fiber.Ctx) error {
|
||||
// Validate state to prevent CSRF
|
||||
cookieState := c.Cookies("oauth_state")
|
||||
queryState := c.Query("state")
|
||||
if cookieState == "" || cookieState != queryState {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": i18n.T_(c, "error.err_invalid_token"),
|
||||
})
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "oauth_state",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-time.Hour),
|
||||
HTTPOnly: true,
|
||||
Secure: h.config.App.Environment == "production",
|
||||
SameSite: "Lax",
|
||||
})
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": i18n.T_(c, "error.err_invalid_body"),
|
||||
})
|
||||
}
|
||||
|
||||
response, rawRefreshToken, err := h.authService.HandleGoogleCallback(code)
|
||||
if err != nil {
|
||||
log.Printf("Google OAuth callback error: %v", err)
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
// Set cookies for web-based authentication (including is_authenticated flag)
|
||||
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
||||
|
||||
// Redirect to the locale-prefixed charts page after successful Google login.
|
||||
// The user's preferred language is stored in the auth response; fall back to "en".
|
||||
lang := response.User.Lang
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
return c.Redirect().To(h.config.App.BaseURL + "/" + lang + "/chart")
|
||||
}
|
||||
26
app/delivery/web/public/bo.go
Normal file
26
app/delivery/web/public/bo.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/assets"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
)
|
||||
|
||||
func InitBo(app *fiber.App) {
|
||||
// static files
|
||||
app.Get("/*", static.New("", static.Config{
|
||||
FS: assets.FS(),
|
||||
// Browse: true,
|
||||
MaxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
}))
|
||||
|
||||
app.Get("/*", static.New("", static.Config{
|
||||
FS: assets.FSDist(),
|
||||
// Browse: true,
|
||||
MaxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
}))
|
||||
|
||||
app.Get("/*", func(c fiber.Ctx) error {
|
||||
return c.SendFile("./assets/public/dist/index.html")
|
||||
})
|
||||
}
|
||||
17
app/delivery/web/public/favicon.go
Normal file
17
app/delivery/web/public/favicon.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/assets"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func Favicon(app *fiber.App, cfg *config.Config) {
|
||||
// Favicon check endpoint
|
||||
app.Get("/favicon.ico", func(c fiber.Ctx) error {
|
||||
return c.SendFile("img/favicon.ico", fiber.SendFile{
|
||||
FS: assets.FS(),
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
20
app/delivery/web/public/health.go
Normal file
20
app/delivery/web/public/health.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func InitHealth(app *fiber.App, cfg *config.Config) {
|
||||
// Health check endpoint
|
||||
app.Get("/health", func(c fiber.Ctx) error {
|
||||
// emailService.NewEmailService().SendVerificationEmail("goc_daniel@ma-al.com", "jakis_token", c.BaseURL(), "en")
|
||||
// emailService.NewEmailService().SendPasswordResetEmail("goc_daniel@ma-al.com", "jakis_token", c.BaseURL(), "en")
|
||||
// emailService.NewEmailService().SendNewUserAdminNotification("goc_daniel@ma-al.com", "admin", c.BaseURL())
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"app": cfg.App.Name,
|
||||
"version": cfg.App.Version,
|
||||
})
|
||||
})
|
||||
}
|
||||
48
app/delivery/web/public/languages.go
Normal file
48
app/delivery/web/public/languages.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type LangHandler struct {
|
||||
service langs.LangService
|
||||
}
|
||||
|
||||
func NewLangHandler() *LangHandler {
|
||||
return &LangHandler{
|
||||
service: *langs.LangSrv,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LangHandler) InitLanguage(api fiber.Router, cfg *config.Config) {
|
||||
|
||||
api.Get("langs", h.GetLanguages)
|
||||
api.Get("translations", h.GetTranslations)
|
||||
api.Get("translations/reload", h.ReloadTranslations)
|
||||
}
|
||||
|
||||
func (h *LangHandler) GetLanguages(c fiber.Ctx) error {
|
||||
return c.JSON(h.service.GetActive(c))
|
||||
}
|
||||
|
||||
func (h *LangHandler) GetTranslations(c fiber.Ctx) error {
|
||||
langIDStr := c.Query("lang_id", "0")
|
||||
langID, _ := strconv.Atoi(langIDStr)
|
||||
scope := c.Query("scope", "")
|
||||
componentsStr := c.Query("components", "")
|
||||
|
||||
var components []string
|
||||
if componentsStr != "" {
|
||||
components = []string{componentsStr}
|
||||
}
|
||||
|
||||
return c.JSON(h.service.GetTranslations(c, uint(langID), scope, components))
|
||||
}
|
||||
|
||||
func (h *LangHandler) ReloadTranslations(c fiber.Ctx) error {
|
||||
return c.JSON(h.service.ReloadTranslationsResponse(c))
|
||||
}
|
||||
179
app/delivery/web/public/repo.go
Normal file
179
app/delivery/web/public/repo.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/repoService"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// RepoHandler handles endpoints asking for repository data (to create charts)
|
||||
type RepoHandler struct {
|
||||
repoService *repoService.RepoService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewRepoHandler creates a new RepoHandler instance
|
||||
func NewRepoHandler() *RepoHandler {
|
||||
repoService := repoService.New()
|
||||
return &RepoHandler{
|
||||
repoService: repoService,
|
||||
config: config.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// RepoHandlerRoutes registers all repo routes
|
||||
func RepoHandlerRoutes(r fiber.Router) fiber.Router {
|
||||
handler := NewRepoHandler()
|
||||
|
||||
r.Get("/get-repos", handler.GetRepoIDs)
|
||||
r.Get("/get-years", handler.GetYears)
|
||||
r.Get("/get-quarters", handler.GetQuarters)
|
||||
r.Get("/get-issues", handler.GetIssues)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetRepoIDs(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
response, err := h.repoService.GetRepositoriesForUser(userID)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetYears(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
repoID_attribute := c.Query("repoID")
|
||||
repoID, err := strconv.Atoi(repoID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
response, err := h.repoService.GetYearsForUser(userID, uint(repoID))
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetQuarters(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
repoID_attribute := c.Query("repoID")
|
||||
repoID, err := strconv.Atoi(repoID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
year_attribute := c.Query("year")
|
||||
year, err := strconv.Atoi(year_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadYearAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadYearAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
response, err := h.repoService.GetQuartersForUser(userID, uint(repoID), uint(year))
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *RepoHandler) GetIssues(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(view.GetErrorStatus(view.ErrInvalidBody)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrInvalidBody),
|
||||
})
|
||||
}
|
||||
|
||||
repoID_attribute := c.Query("repoID")
|
||||
repoID, err := strconv.Atoi(repoID_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadRepoIDAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadRepoIDAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
year_attribute := c.Query("year")
|
||||
year, err := strconv.Atoi(year_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadYearAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadYearAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
quarter_attribute := c.Query("quarter")
|
||||
quarter, err := strconv.Atoi(quarter_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadQuarterAttribute)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadQuarterAttribute),
|
||||
})
|
||||
}
|
||||
|
||||
page_number_attribute := c.Query("page_number")
|
||||
page_number, err := strconv.Atoi(page_number_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadPaging)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadPaging),
|
||||
})
|
||||
}
|
||||
|
||||
elements_per_page_attribute := c.Query("quarter")
|
||||
elements_per_page, err := strconv.Atoi(elements_per_page_attribute)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(view.ErrBadPaging)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, view.ErrBadPaging),
|
||||
})
|
||||
}
|
||||
|
||||
var paging pagination.Paging
|
||||
paging.Page = uint(page_number)
|
||||
paging.Elements = uint(elements_per_page)
|
||||
|
||||
response, err := h.repoService.GetIssuesForUser(userID, uint(repoID), uint(year), uint(quarter), paging)
|
||||
if err != nil {
|
||||
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
|
||||
"error": view.GetErrorCode(c, err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
91
app/delivery/web/public/settings.go
Normal file
91
app/delivery/web/public/settings.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/nullable"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/response"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/version"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// SettingsResponse represents the settings endpoint response
|
||||
type SettingsResponse struct {
|
||||
App AppSettings `json:"app"`
|
||||
Server ServerSettings `json:"server"`
|
||||
Auth AuthSettings `json:"auth"`
|
||||
Features FeatureFlags `json:"features"`
|
||||
Version version.Info `json:"version"`
|
||||
}
|
||||
|
||||
// AppSettings represents app configuration
|
||||
type AppSettings struct {
|
||||
Name string `json:"name"`
|
||||
Environment string `json:"environment"`
|
||||
BaseURL string `json:"base_url"`
|
||||
PasswordRegex string `json:"password_regex"`
|
||||
// Config config.Config `json:"config"`
|
||||
}
|
||||
|
||||
// ServerSettings represents server configuration (non-sensitive)
|
||||
type ServerSettings struct {
|
||||
Port int `json:"port"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
// AuthSettings represents auth configuration (non-sensitive)
|
||||
type AuthSettings struct {
|
||||
JWTExpiration int `json:"jwt_expiration"`
|
||||
RefreshExpiration int `json:"refresh_expiration"`
|
||||
}
|
||||
|
||||
// FeatureFlags represents feature flags
|
||||
type FeatureFlags struct {
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
OAuthGoogle bool `json:"oauth_google"`
|
||||
}
|
||||
|
||||
// SettingsHandler handles settings/config endpoints
|
||||
type SettingsHandler struct{}
|
||||
|
||||
// NewSettingsHandler creates a new settings handler
|
||||
func NewSettingsHandler() *SettingsHandler {
|
||||
return &SettingsHandler{}
|
||||
}
|
||||
|
||||
// InitSettings initializes the settings routes
|
||||
func (h *SettingsHandler) InitSettings(api fiber.Router, cfg *config.Config) {
|
||||
settings := api.Group("/settings")
|
||||
settings.Get("", h.GetSettings(cfg))
|
||||
}
|
||||
|
||||
// GetSettings returns all settings/config
|
||||
func (h *SettingsHandler) GetSettings(cfg *config.Config) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
settings := SettingsResponse{
|
||||
App: AppSettings{
|
||||
Name: cfg.App.Name,
|
||||
Environment: cfg.App.Environment,
|
||||
BaseURL: cfg.App.BaseURL,
|
||||
PasswordRegex: constdata.PASSWORD_VALIDATION_REGEX,
|
||||
// Config: *config.Get(),
|
||||
},
|
||||
Server: ServerSettings{
|
||||
Port: cfg.Server.Port,
|
||||
Host: cfg.Server.Host,
|
||||
},
|
||||
Auth: AuthSettings{
|
||||
JWTExpiration: cfg.Auth.JWTExpiration,
|
||||
RefreshExpiration: cfg.Auth.RefreshExpiration,
|
||||
},
|
||||
Features: FeatureFlags{
|
||||
EmailEnabled: cfg.Email.Enabled,
|
||||
OAuthGoogle: cfg.OAuth.Google.ClientID != "",
|
||||
},
|
||||
Version: version.GetInfo(),
|
||||
}
|
||||
|
||||
return c.JSON(response.Make(c, fiber.StatusOK, nullable.GetNil(settings), nullable.GetNil(0), i18n.T_(c, response.Message_OK)))
|
||||
}
|
||||
}
|
||||
60
app/delivery/web/public/swagger.go
Normal file
60
app/delivery/web/public/swagger.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/api"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
)
|
||||
|
||||
func InitSwagger(app *fiber.App) {
|
||||
// Swagger - serve OpenAPI JSON
|
||||
app.Get("/openapi.json", func(c fiber.Ctx) error {
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.SendString(api.ApenapiJson)
|
||||
})
|
||||
|
||||
// Swagger UI HTML
|
||||
app.Get("/swagger", func(c fiber.Ctx) error {
|
||||
return c.Redirect().Status(fiber.StatusFound).To("/swagger/index.html")
|
||||
})
|
||||
|
||||
app.Get("/swagger/index.html", func(c fiber.Ctx) error {
|
||||
c.Set("Content-Type", "text/html")
|
||||
return c.SendString(swaggerHTML)
|
||||
})
|
||||
|
||||
// Serve Swagger assets
|
||||
app.Get("/swagger/assets", static.New("app/api/swagger/assets"))
|
||||
}
|
||||
|
||||
// Embedded Swagger UI HTML (minimal version)
|
||||
var swaggerHTML = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Documentation</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" charset="UTF-8"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
68
app/langs/langs.go
Normal file
68
app/langs/langs.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package langs_repo
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/db"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
type LangsRepo struct{}
|
||||
|
||||
func New() *LangsRepo {
|
||||
return &LangsRepo{}
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetActive() ([]view.Language, error) {
|
||||
langs := []view.Language{}
|
||||
err := db.DB.Model(model.Language{}).Find(&langs, model.Language{Active: true}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return langs, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetAllTranslations() ([]model.Translation, error) {
|
||||
var translations []model.Translation
|
||||
err := db.DB.Preload("Language").Preload("Scope").Preload("Component").Find(&translations).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetTranslationsByLangID(langID uint) ([]model.Translation, error) {
|
||||
var translations []model.Translation
|
||||
err := db.DB.Preload("Language").Preload("Scope").Preload("Component").
|
||||
Where("lang_id = ?", langID).Find(&translations).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetDefault() (*view.Language, error) {
|
||||
var lang view.Language
|
||||
err := db.DB.Model(model.Language{}).Where("is_default = ?", true).First(&lang).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lang, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetByISOCode(isoCode string) (*view.Language, error) {
|
||||
var lang view.Language
|
||||
err := db.DB.Model(model.Language{}).First(&lang, model.Language{ISOCode: isoCode}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lang, nil
|
||||
}
|
||||
|
||||
func (r *LangsRepo) GetById(id uint) (*view.Language, error) {
|
||||
var lang view.Language
|
||||
err := db.DB.Model(model.Language{}).First(&lang, model.Language{ID: id}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lang, nil
|
||||
}
|
||||
144
app/model/customer.go
Normal file
144
app/model/customer.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type Customer struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email"`
|
||||
Password string `gorm:"size:255" json:"-"` // Hashed password, not exposed in JSON
|
||||
FirstName string `gorm:"size:100" json:"first_name"`
|
||||
LastName string `gorm:"size:100" json:"last_name"`
|
||||
Role CustomerRole `gorm:"type:varchar(20);default:'user'" json:"role"`
|
||||
Provider AuthProvider `gorm:"type:varchar(20);default:'local'" json:"provider"`
|
||||
ProviderID string `gorm:"size:255" json:"provider_id,omitempty"` // ID from OAuth provider
|
||||
AvatarURL string `gorm:"size:500" json:"avatar_url,omitempty"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
EmailVerified bool `gorm:"default:false" json:"email_verified"`
|
||||
EmailVerificationToken string `gorm:"size:255" json:"-"`
|
||||
EmailVerificationExpires *time.Time `json:"-"`
|
||||
PasswordResetToken string `gorm:"size:255" json:"-"`
|
||||
PasswordResetExpires *time.Time `json:"-"`
|
||||
LastPasswordResetRequest *time.Time `json:"-"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
Lang string `gorm:"size:10;default:'en'" json:"lang"` // User's preferred language
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// CustomerRole represents the role of a user
|
||||
type CustomerRole string
|
||||
|
||||
const (
|
||||
RoleUser CustomerRole = "user"
|
||||
RoleAdmin CustomerRole = "admin"
|
||||
)
|
||||
|
||||
// AuthProvider represents the authentication provider
|
||||
type AuthProvider string
|
||||
|
||||
const (
|
||||
ProviderLocal AuthProvider = "local"
|
||||
ProviderGoogle AuthProvider = "google"
|
||||
)
|
||||
|
||||
// TableName specifies the table name for User model
|
||||
func (Customer) TableName() string {
|
||||
return "customers"
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (u *Customer) IsAdmin() bool {
|
||||
return u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
// CanManageUsers checks if the user can manage other users
|
||||
func (u *Customer) CanManageUsers() bool {
|
||||
return u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
// FullName returns the user's full name
|
||||
func (u *Customer) FullName() string {
|
||||
if u.FirstName == "" && u.LastName == "" {
|
||||
return u.Email
|
||||
}
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
|
||||
// UserSession represents a user session for JWT claims
|
||||
type UserSession struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role CustomerRole `json:"role"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
// ToSession converts User to UserSession
|
||||
func (u *Customer) ToSession() *UserSession {
|
||||
return &UserSession{
|
||||
UserID: u.ID,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
Lang: u.Lang,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequest represents the login form data
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" form:"email"`
|
||||
Password string `json:"password" form:"password"`
|
||||
}
|
||||
|
||||
// RegisterRequest represents the initial registration form data
|
||||
type RegisterRequest struct {
|
||||
ErrorMsg string `form:"error_msg" json:"error_msg"`
|
||||
Email string `json:"email" form:"email"`
|
||||
Password string `json:"password" form:"password"`
|
||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password"`
|
||||
FirstName string `json:"first_name" form:"first_name"`
|
||||
LastName string `json:"last_name" form:"last_name"`
|
||||
Lang string `form:"lang" json:"lang"`
|
||||
}
|
||||
|
||||
// CompleteRegistrationRequest represents the completion of registration with email verification
|
||||
type CompleteRegistrationRequest struct {
|
||||
Token string `json:"token" form:"token"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest represents the reset password form data
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token" form:"token"`
|
||||
Password string `json:"password" form:"password"`
|
||||
}
|
||||
|
||||
// AuthResponse represents the authentication response
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User *UserSession `json:"user"`
|
||||
}
|
||||
|
||||
// RefreshToken represents an opaque refresh token stored in the database
|
||||
type RefreshToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
CustomerID uint `gorm:"not null;index" json:"-"`
|
||||
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"` // SHA-256 hex of the raw token
|
||||
ExpiresAt time.Time `gorm:"not null" json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for RefreshToken model
|
||||
func (RefreshToken) TableName() string {
|
||||
return "refresh_tokens"
|
||||
}
|
||||
22
app/model/data.go
Normal file
22
app/model/data.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
// LoginRequest represents the login form data
|
||||
type DataRequest struct {
|
||||
RepoID uint `json:"repoid" form:"repoid"`
|
||||
Step uint `json:"step" form:"step"`
|
||||
}
|
||||
|
||||
type PageMeta struct {
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
type QuarterData struct {
|
||||
Time float64 `json:"time"`
|
||||
Quarter string `json:"quarter"`
|
||||
}
|
||||
|
||||
type DayData struct {
|
||||
Date string `json:"date"`
|
||||
Time float64 `json:"time"`
|
||||
}
|
||||
67
app/model/i18n.go
Normal file
67
app/model/i18n.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// contextKey is a custom type for context keys
|
||||
type contextKey string
|
||||
|
||||
// ContextLanguageID is the key for storing language ID in context
|
||||
const ContextLanguageID contextKey = "languageID"
|
||||
|
||||
type Translation struct {
|
||||
LangID uint `gorm:"primaryKey;column:lang_id"`
|
||||
ScopeID uint `gorm:"primaryKey;column:scope_id"`
|
||||
ComponentID uint `gorm:"primaryKey;column:component_id"`
|
||||
Key string `gorm:"primaryKey;size:255;column:key"`
|
||||
Data *string `gorm:"type:text;column:data"`
|
||||
|
||||
Language Language `gorm:"foreignKey:LangID;references:ID;constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT"`
|
||||
Scope Scope `gorm:"foreignKey:ScopeID;references:ID;constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT"`
|
||||
Component Component `gorm:"foreignKey:ComponentID;references:ID;constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT"`
|
||||
}
|
||||
|
||||
func (Translation) TableName() string {
|
||||
return "translations"
|
||||
}
|
||||
|
||||
type Language struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement;column:id"`
|
||||
CreatedAt time.Time `gorm:"not null;column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_language_deleted_at;column:deleted_at"`
|
||||
Name string `gorm:"size:128;not null;column:name"`
|
||||
ISOCode string `gorm:"size:2;not null;column:iso_code"`
|
||||
LangCode string `gorm:"size:5;not null;column:lang_code"`
|
||||
DateFormat string `gorm:"size:32;not null;column:date_format"`
|
||||
DateFormatShort string `gorm:"size:32;not null;column:date_format_short"`
|
||||
RTL bool `gorm:"not null;default:0;column:rtl"`
|
||||
IsDefault bool `gorm:"not null;default:0;column:is_default;comment:there should be only one default language"`
|
||||
Active bool `gorm:"not null;default:1;column:active"`
|
||||
Flag string `gorm:"size:16;not null;column:flag"`
|
||||
}
|
||||
|
||||
func (Language) TableName() string {
|
||||
return "language"
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement;column:id"`
|
||||
Name string `gorm:"size:255;not null;uniqueIndex:uk_components_name;column:name"`
|
||||
}
|
||||
|
||||
func (Component) TableName() string {
|
||||
return "components"
|
||||
}
|
||||
|
||||
type Scope struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement;column:id"`
|
||||
Name string `gorm:"size:255;not null;uniqueIndex:uk_scopes_name;column:name"`
|
||||
}
|
||||
|
||||
func (Scope) TableName() string {
|
||||
return "scopes"
|
||||
}
|
||||
61
app/model/repository.go
Normal file
61
app/model/repository.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Repository struct {
|
||||
ID int64 `db:"id"`
|
||||
OwnerID *int64 `db:"owner_id"`
|
||||
OwnerName *string `db:"owner_name"`
|
||||
LowerName string `db:"lower_name"`
|
||||
Name string `db:"name"`
|
||||
Description *string `db:"description"`
|
||||
Website *string `db:"website"`
|
||||
OriginalServiceType *int `db:"original_service_type"`
|
||||
OriginalURL *string `db:"original_url"`
|
||||
DefaultBranch *string `db:"default_branch"`
|
||||
DefaultWikiBranch *string `db:"default_wiki_branch"`
|
||||
|
||||
NumWatches *int `db:"num_watches"`
|
||||
NumStars *int `db:"num_stars"`
|
||||
NumForks *int `db:"num_forks"`
|
||||
NumIssues *int `db:"num_issues"`
|
||||
NumClosedIssues *int `db:"num_closed_issues"`
|
||||
NumPulls *int `db:"num_pulls"`
|
||||
NumClosedPulls *int `db:"num_closed_pulls"`
|
||||
|
||||
NumMilestones int `db:"num_milestones"`
|
||||
NumClosedMilestones int `db:"num_closed_milestones"`
|
||||
NumProjects int `db:"num_projects"`
|
||||
NumClosedProjects int `db:"num_closed_projects"`
|
||||
NumActionRuns int `db:"num_action_runs"`
|
||||
NumClosedActionRuns int `db:"num_closed_action_runs"`
|
||||
|
||||
IsPrivate *bool `db:"is_private"`
|
||||
IsEmpty *bool `db:"is_empty"`
|
||||
IsArchived *bool `db:"is_archived"`
|
||||
IsMirror *bool `db:"is_mirror"`
|
||||
|
||||
Status int `db:"status"`
|
||||
IsFork bool `db:"is_fork"`
|
||||
ForkID *int64 `db:"fork_id"`
|
||||
|
||||
IsTemplate bool `db:"is_template"`
|
||||
TemplateID *int64 `db:"template_id"`
|
||||
|
||||
Size int64 `db:"size"`
|
||||
GitSize int64 `db:"git_size"`
|
||||
LFSSize int64 `db:"lfs_size"`
|
||||
|
||||
IsFsckEnabled bool `db:"is_fsck_enabled"`
|
||||
CloseIssuesViaCommitAnyBranch bool `db:"close_issues_via_commit_in_any_branch"`
|
||||
|
||||
Topics json.RawMessage `db:"topics"`
|
||||
|
||||
ObjectFormatName string `db:"object_format_name"`
|
||||
TrustModel *int `db:"trust_model"`
|
||||
Avatar *string `db:"avatar"`
|
||||
|
||||
CreatedUnix *int64 `db:"created_unix"`
|
||||
UpdatedUnix *int64 `db:"updated_unix"`
|
||||
ArchivedUnix int64 `db:"archived_unix"`
|
||||
}
|
||||
509
app/service/authService/auth.go
Normal file
509
app/service/authService/auth.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package authService
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/db"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/emailService"
|
||||
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// JWTClaims represents the JWT claims
|
||||
type JWTClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role model.CustomerRole `json:"customer_role"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
config *config.AuthConfig
|
||||
email *emailService.EmailService
|
||||
}
|
||||
|
||||
// NewAuthService creates a new AuthService instance
|
||||
func NewAuthService() *AuthService {
|
||||
svc := &AuthService{
|
||||
db: db.Get(),
|
||||
config: &config.Get().Auth,
|
||||
email: emailService.NewEmailService(),
|
||||
}
|
||||
// Auto-migrate the refresh_tokens table
|
||||
if svc.db != nil {
|
||||
_ = svc.db.AutoMigrate(&model.RefreshToken{})
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// Login authenticates a user with email and password
|
||||
func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, string, error) {
|
||||
var user model.Customer
|
||||
|
||||
// Find user by email
|
||||
if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, "", view.ErrInvalidCredentials
|
||||
}
|
||||
return nil, "", fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return nil, "", view.ErrUserInactive
|
||||
}
|
||||
|
||||
// Check if email is verified
|
||||
if !user.EmailVerified {
|
||||
return nil, "", view.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
return nil, "", view.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
s.db.Save(&user)
|
||||
|
||||
// Generate access token (JWT)
|
||||
accessToken, err := s.generateAccessToken(&user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate opaque refresh token and store in DB
|
||||
rawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, rawRefreshToken, nil
|
||||
}
|
||||
|
||||
// Register initiates user registration
|
||||
func (s *AuthService) Register(req *model.RegisterRequest) error {
|
||||
// Check if email already exists
|
||||
var existingUser model.Customer
|
||||
if err := s.db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
|
||||
return view.ErrEmailExists
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if req.Password != req.ConfirmPassword {
|
||||
return view.ErrPasswordsDoNotMatch
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if err := validatePassword(req.Password); err != nil {
|
||||
return view.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
token, err := s.generateVerificationToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate verification token: %w", err)
|
||||
}
|
||||
|
||||
// Set expiration (24 hours from now)
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
|
||||
// Create user with verification token
|
||||
user := model.Customer{
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Role: model.RoleUser,
|
||||
Provider: model.ProviderLocal,
|
||||
IsActive: false,
|
||||
EmailVerified: false,
|
||||
EmailVerificationToken: token,
|
||||
EmailVerificationExpires: &expiresAt,
|
||||
Lang: req.Lang,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Send verification email
|
||||
baseURL := config.Get().App.BaseURL
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = "en" // Default to English
|
||||
}
|
||||
if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil {
|
||||
// Log error but don't fail registration - user can request resend
|
||||
_ = err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteRegistration completes the registration with password verification after email verification
|
||||
func (s *AuthService) CompleteRegistration(req *model.CompleteRegistrationRequest) (*model.AuthResponse, string, error) {
|
||||
// Find user by verification token
|
||||
var user model.Customer
|
||||
if err := s.db.Where("email_verification_token = ?", req.Token).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, "", view.ErrInvalidVerificationToken
|
||||
}
|
||||
return nil, "", fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if user.EmailVerificationExpires != nil && user.EmailVerificationExpires.Before(time.Now()) {
|
||||
return nil, "", view.ErrVerificationTokenExpired
|
||||
}
|
||||
|
||||
// Update user - activate account and mark email as verified
|
||||
user.IsActive = true
|
||||
user.EmailVerified = true
|
||||
user.EmailVerificationToken = ""
|
||||
user.EmailVerificationExpires = nil
|
||||
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return nil, "", fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
// Send admin notification about new user registration
|
||||
baseURL := config.Get().App.BaseURL
|
||||
if err := s.email.SendNewUserAdminNotification(user.Email, user.FullName(), baseURL); err != nil {
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
accessToken, err := s.generateAccessToken(&user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate opaque refresh token and store in DB
|
||||
rawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, rawRefreshToken, nil
|
||||
}
|
||||
|
||||
// RequestPasswordReset initiates a password reset request
|
||||
func (s *AuthService) RequestPasswordReset(emailAddr string) error {
|
||||
// Find user by email
|
||||
var user model.Customer
|
||||
if err := s.db.Where("email = ?", emailAddr).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Don't reveal if email exists or not for security
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check if user is active, email verified, and is a local user
|
||||
if !user.IsActive || !user.EmailVerified || user.Provider != model.ProviderLocal {
|
||||
// Don't reveal account status for security
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check rate limit: don't allow password reset requests more than once per hour
|
||||
if user.LastPasswordResetRequest != nil && time.Since(*user.LastPasswordResetRequest) < time.Hour {
|
||||
// Rate limit hit, silently fail for security
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
token, err := s.generateVerificationToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate reset token: %w", err)
|
||||
}
|
||||
|
||||
// Set expiration (1 hour from now)
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
// Update user with reset token and last request time
|
||||
now := time.Now()
|
||||
user.PasswordResetToken = token
|
||||
user.PasswordResetExpires = &expiresAt
|
||||
user.LastPasswordResetRequest = &now
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to save reset token: %w", err)
|
||||
}
|
||||
|
||||
// Send password reset email
|
||||
baseURL := config.Get().App.BaseURL
|
||||
lang := "en"
|
||||
if user.Lang != "" {
|
||||
lang = user.Lang
|
||||
}
|
||||
if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil {
|
||||
_ = err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword completes the password reset with a new password
|
||||
func (s *AuthService) ResetPassword(token, newPassword string) error {
|
||||
// Find user by reset token
|
||||
var user model.Customer
|
||||
if err := s.db.Where("password_reset_token = ?", token).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return view.ErrInvalidResetToken
|
||||
}
|
||||
return fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if user.PasswordResetExpires == nil || user.PasswordResetExpires.Before(time.Now()) {
|
||||
return view.ErrResetTokenExpired
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return view.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Update user password and clear reset token
|
||||
user.Password = string(hashedPassword)
|
||||
user.PasswordResetToken = ""
|
||||
user.PasswordResetExpires = nil
|
||||
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Revoke all existing refresh tokens for this user (security: password changed)
|
||||
s.db.Where("customer_id = ?", user.ID).Delete(&model.RefreshToken{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT access token and returns the claims
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, view.ErrTokenExpired
|
||||
}
|
||||
return nil, view.ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*JWTClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, view.ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// RefreshToken validates an opaque refresh token, rotates it, and returns a new access token.
|
||||
// Returns: AuthResponse, new raw refresh token, error
|
||||
func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string, error) {
|
||||
tokenHash := hashToken(rawToken)
|
||||
|
||||
// Find the refresh token record
|
||||
var rt model.RefreshToken
|
||||
if err := s.db.Where("token_hash = ?", tokenHash).First(&rt).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, "", view.ErrInvalidToken
|
||||
}
|
||||
return nil, "", fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if rt.ExpiresAt.Before(time.Now()) {
|
||||
// Clean up expired token
|
||||
s.db.Delete(&rt)
|
||||
return nil, "", view.ErrTokenExpired
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user model.Customer
|
||||
if err := s.db.First(&user, rt.CustomerID).Error; err != nil {
|
||||
return nil, "", view.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, "", view.ErrUserInactive
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return nil, "", view.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
// Delete the old refresh token (rotation: one-time use)
|
||||
s.db.Delete(&rt)
|
||||
|
||||
// Generate new access token
|
||||
accessToken, err := s.generateAccessToken(&user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Issue a new opaque refresh token
|
||||
newRawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, newRawRefreshToken, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken deletes a specific refresh token from the DB (used on logout)
|
||||
func (s *AuthService) RevokeRefreshToken(rawToken string) {
|
||||
if rawToken == "" {
|
||||
return
|
||||
}
|
||||
tokenHash := hashToken(rawToken)
|
||||
s.db.Where("token_hash = ?", tokenHash).Delete(&model.RefreshToken{})
|
||||
}
|
||||
|
||||
// RevokeAllRefreshTokens deletes all refresh tokens for a user (used on logout-all-devices)
|
||||
func (s *AuthService) RevokeAllRefreshTokens(userID uint) {
|
||||
s.db.Where("customer_id = ?", userID).Delete(&model.RefreshToken{})
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) {
|
||||
var user model.Customer
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, view.ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
func (s *AuthService) GetUserByEmail(email string) (*model.Customer, error) {
|
||||
var user model.Customer
|
||||
if err := s.db.Where("email = ?", email).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, view.ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// createRefreshToken generates a random opaque token, stores its hash in the DB, and returns the raw token.
|
||||
func (s *AuthService) createRefreshToken(userID uint) (string, error) {
|
||||
// Generate 32 random bytes → 64-char hex string
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rawToken := hex.EncodeToString(b)
|
||||
tokenHash := hashToken(rawToken)
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(s.config.RefreshExpiration) * time.Second)
|
||||
|
||||
rt := model.RefreshToken{
|
||||
CustomerID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := s.db.Create(&rt).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return rawToken, nil
|
||||
}
|
||||
|
||||
// hashToken returns the SHA-256 hex digest of a raw token string.
|
||||
func hashToken(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// generateAccessToken generates a short-lived JWT access token
|
||||
func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) {
|
||||
claims := JWTClaims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Email,
|
||||
Role: user.Role,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.config.JWTSecret))
|
||||
}
|
||||
|
||||
// generateVerificationToken generates a random verification token
|
||||
func (s *AuthService) generateVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// validatePassword validates password strength using RE2-compatible regexes.
|
||||
func validatePassword(password string) error {
|
||||
var passregex2 = regexp2.MustCompile(constdata.PASSWORD_VALIDATION_REGEX, regexp2.None)
|
||||
|
||||
if ok, _ := passregex2.MatchString(password); !ok {
|
||||
return errors.New("password must be at least 10 characters long and contain at least one lowercase letter, one uppercase letter, and one digit")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
204
app/service/authService/google_oauth.go
Normal file
204
app/service/authService/google_oauth.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package authService
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
const googleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
|
||||
// GoogleUserInfo represents the user info returned by Google
|
||||
type GoogleUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
VerifiedEmail bool `json:"verified_email"`
|
||||
Name string `json:"name"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// googleOAuthConfig returns the OAuth2 config for Google
|
||||
func googleOAuthConfig() *oauth2.Config {
|
||||
cfg := config.Get().OAuth.Google
|
||||
scopes := cfg.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
}
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: scopes,
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateOAuthState generates a random state token for CSRF protection
|
||||
func (s *AuthService) GenerateOAuthState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GetGoogleAuthURL returns the Google OAuth2 authorization URL with a state token
|
||||
func (s *AuthService) GetGoogleAuthURL(state string) string {
|
||||
return googleOAuthConfig().AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
}
|
||||
|
||||
// HandleGoogleCallback exchanges the code for a token, fetches user info,
|
||||
// and either logs in or registers the user, returning an AuthResponse and raw refresh token.
|
||||
func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, string, error) {
|
||||
oauthCfg := googleOAuthConfig()
|
||||
|
||||
// Exchange authorization code for token
|
||||
token, err := oauthCfg.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
// Fetch user info from Google
|
||||
userInfo, err := fetchGoogleUserInfo(oauthCfg.Client(context.Background(), token))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to fetch user info: %w", err)
|
||||
}
|
||||
|
||||
if !userInfo.VerifiedEmail {
|
||||
return nil, "", view.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
user, err := s.findOrCreateGoogleUser(userInfo)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Update last login
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
s.db.Save(user)
|
||||
|
||||
// Generate access token (JWT)
|
||||
accessToken, err := s.generateAccessToken(user)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate opaque refresh token and store in DB
|
||||
rawRefreshToken, err := s.createRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &model.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: s.config.JWTExpiration,
|
||||
User: user.ToSession(),
|
||||
}, rawRefreshToken, nil
|
||||
}
|
||||
|
||||
// findOrCreateGoogleUser finds an existing user by Google provider ID or email,
|
||||
// or creates a new one.
|
||||
func (s *AuthService) findOrCreateGoogleUser(info *GoogleUserInfo) (*model.Customer, error) {
|
||||
var user model.Customer
|
||||
|
||||
// Try to find by provider + provider_id
|
||||
err := s.db.Where("provider = ? AND provider_id = ?", model.ProviderGoogle, info.ID).First(&user).Error
|
||||
if err == nil {
|
||||
// Update avatar in case it changed
|
||||
user.AvatarURL = info.Picture
|
||||
s.db.Save(&user)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Try to find by email (user may have registered locally before)
|
||||
err = s.db.Where("email = ?", info.Email).First(&user).Error
|
||||
if err == nil {
|
||||
// Link Google provider to existing account
|
||||
user.Provider = model.ProviderGoogle
|
||||
user.ProviderID = info.ID
|
||||
user.AvatarURL = info.Picture
|
||||
user.IsActive = true
|
||||
s.db.Save(&user)
|
||||
|
||||
// If email has not been verified yet, send email to admin.
|
||||
if !user.EmailVerified {
|
||||
baseURL := config.Get().App.BaseURL
|
||||
if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil {
|
||||
// Log error but don't fail registration
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
user.EmailVerified = true
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Create new user
|
||||
newUser := model.Customer{
|
||||
Email: info.Email,
|
||||
FirstName: info.GivenName,
|
||||
LastName: info.FamilyName,
|
||||
Provider: model.ProviderGoogle,
|
||||
ProviderID: info.ID,
|
||||
AvatarURL: info.Picture,
|
||||
Role: model.RoleUser,
|
||||
IsActive: true,
|
||||
EmailVerified: true,
|
||||
Lang: "en",
|
||||
}
|
||||
|
||||
if err := s.db.Create(&newUser).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// If everything succeeded, send email to admin.
|
||||
if !user.EmailVerified {
|
||||
baseURL := config.Get().App.BaseURL
|
||||
if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil {
|
||||
// Log error but don't fail registration
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
return &newUser, nil
|
||||
}
|
||||
|
||||
// fetchGoogleUserInfo fetches user info from Google using the provided HTTP client
|
||||
func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) {
|
||||
resp, err := client.Get(googleUserInfoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userInfo GoogleUserInfo
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &userInfo, nil
|
||||
}
|
||||
138
app/service/emailService/email.go
Normal file
138
app/service/emailService/email.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package emailService
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/config"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/emails"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
// EmailService handles sending emails
|
||||
type EmailService struct {
|
||||
config *config.EmailConfig
|
||||
}
|
||||
|
||||
// NewEmailService creates a new EmailService instance
|
||||
func NewEmailService() *EmailService {
|
||||
return &EmailService{
|
||||
config: &config.Get().Email,
|
||||
}
|
||||
}
|
||||
|
||||
// getLangID returns the language ID from the ISO code using the language service
|
||||
func getLangID(isoCode string) uint {
|
||||
if isoCode == "" {
|
||||
isoCode = "en"
|
||||
}
|
||||
|
||||
lang, err := langs.LangSrv.GetLanguageByISOCode(isoCode)
|
||||
if err != nil || lang == nil {
|
||||
return 1 // Default to English (ID 1)
|
||||
}
|
||||
|
||||
return uint(lang.ID)
|
||||
}
|
||||
|
||||
// SendEmail sends an email to the specified recipient
|
||||
func (s *EmailService) SendEmail(to, subject, body string) error {
|
||||
if !s.config.Enabled {
|
||||
return fmt.Errorf("email service is disabled")
|
||||
}
|
||||
|
||||
// Set up authentication
|
||||
auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPassword, s.config.SMTPHost)
|
||||
|
||||
// Create email headers
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = fmt.Sprintf("%s <%s>", s.config.FromName, s.config.FromEmail)
|
||||
headers["To"] = to
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=utf-8"
|
||||
|
||||
// Build email message
|
||||
var msg strings.Builder
|
||||
for k, v := range headers {
|
||||
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
// Send email
|
||||
addr := fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort)
|
||||
if err := smtp.SendMail(addr, auth, s.config.FromEmail, []string{to}, []byte(msg.String())); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendVerificationEmail sends an email verification email
|
||||
func (s *EmailService) SendVerificationEmail(to, token, baseURL, lang string) error {
|
||||
// Use default language if not provided
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
verificationURL := fmt.Sprintf("%s/%s/verify-email?token=%s", baseURL, lang, token)
|
||||
|
||||
langID := getLangID(lang)
|
||||
subject := i18n.T___(langID, "email.email_verification_subject")
|
||||
body := s.verificationEmailTemplate(to, verificationURL, langID)
|
||||
|
||||
return s.SendEmail(to, subject, body)
|
||||
}
|
||||
|
||||
// SendPasswordResetEmail sends a password reset email
|
||||
func (s *EmailService) SendPasswordResetEmail(to, token, baseURL, lang string) error {
|
||||
// Use default language if not provided
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
resetURL := fmt.Sprintf("%s/%s/reset-password?token=%s", baseURL, lang, token)
|
||||
|
||||
langID := getLangID(lang)
|
||||
subject := i18n.T___(langID, "email.email_password_reset_subject")
|
||||
body := s.passwordResetEmailTemplate(to, resetURL, langID)
|
||||
|
||||
return s.SendEmail(to, subject, body)
|
||||
}
|
||||
|
||||
// SendNewUserAdminNotification sends an email to admin when a new user completes registration
|
||||
func (s *EmailService) SendNewUserAdminNotification(userEmail, userName, baseURL string) error {
|
||||
if s.config.AdminEmail == "" {
|
||||
return nil // No admin email configured
|
||||
}
|
||||
|
||||
subject := "New User Registration - Repository Assignment Required"
|
||||
body := s.newUserAdminNotificationTemplate(userEmail, userName, baseURL)
|
||||
|
||||
return s.SendEmail(s.config.AdminEmail, subject, body)
|
||||
}
|
||||
|
||||
// verificationEmailTemplate returns the HTML template for email verification
|
||||
func (s *EmailService) verificationEmailTemplate(name, verificationURL string, langID uint) string {
|
||||
buf := bytes.Buffer{}
|
||||
emails.EmailVerificationWrapper(view.EmailLayout[view.EmailVerificationData]{LangID: langID, Data: view.EmailVerificationData{VerificationURL: verificationURL}}).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// passwordResetEmailTemplate returns the HTML template for password reset
|
||||
func (s *EmailService) passwordResetEmailTemplate(name, resetURL string, langID uint) string {
|
||||
buf := bytes.Buffer{}
|
||||
emails.EmailPasswordResetWrapper(view.EmailLayout[view.EmailPasswordResetData]{LangID: langID, Data: view.EmailPasswordResetData{ResetURL: resetURL}}).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// newUserAdminNotificationTemplate returns the HTML template for admin notification
|
||||
func (s *EmailService) newUserAdminNotificationTemplate(userEmail, userName, baseURL string) string {
|
||||
buf := bytes.Buffer{}
|
||||
emails.EmailAdminNotificationWrapper(view.EmailLayout[view.EmailAdminNotificationData]{LangID: 2, Data: view.EmailAdminNotificationData{UserEmail: userEmail, UserName: userName, BaseURL: baseURL}}).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
97
app/service/langs/service.go
Normal file
97
app/service/langs/service.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package langs
|
||||
|
||||
import (
|
||||
langs_repo "git.ma-al.com/goc_marek/timetracker/app/langs"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/nullable"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/response"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type LangService struct {
|
||||
repo langs_repo.LangsRepo
|
||||
}
|
||||
|
||||
type LangServiceMessage i18n.I18nTranslation
|
||||
|
||||
const (
|
||||
Message_LangsLoaded LangServiceMessage = "langs_loaded"
|
||||
Message_LangsNotLoaded LangServiceMessage = "langs_not_loaded"
|
||||
Message_TranslationsOK LangServiceMessage = "translations_loaded"
|
||||
Message_TranslationsNOK LangServiceMessage = "translations_not_loaded"
|
||||
)
|
||||
|
||||
var LangSrv *LangService
|
||||
|
||||
func (s *LangService) GetActive(c fiber.Ctx) response.Response[[]view.Language] {
|
||||
res, err := s.repo.GetActive()
|
||||
if err != nil {
|
||||
return response.Make[[]view.Language](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, response.Message_NOK))
|
||||
}
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(res), nullable.GetNil(len(res)), i18n.T_(c, response.Message_OK))
|
||||
}
|
||||
|
||||
// LoadTranslations loads all translations from the database into the cache
|
||||
func (s *LangService) LoadTranslations() error {
|
||||
translations, err := s.repo.GetAllTranslations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return i18n.TransStore.LoadTranslations(translations)
|
||||
}
|
||||
|
||||
// ReloadTranslations reloads translations from the database into the cache
|
||||
func (s *LangService) ReloadTranslations() error {
|
||||
translations, err := s.repo.GetAllTranslations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return i18n.TransStore.ReloadTranslations(translations)
|
||||
}
|
||||
|
||||
// GetTranslations returns translations from the cache
|
||||
func (s *LangService) GetTranslations(c fiber.Ctx, langID uint, scope string, components []string) response.Response[*i18n.TranslationResponse] {
|
||||
translations, err := i18n.TransStore.GetTranslations(langID, scope, components)
|
||||
if err != nil {
|
||||
return response.Make[*i18n.TranslationResponse](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, Message_TranslationsNOK))
|
||||
}
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK))
|
||||
}
|
||||
|
||||
// GetAllTranslations returns all translations from the cache
|
||||
func (s *LangService) GetAllTranslationsResponse(c fiber.Ctx) response.Response[*i18n.TranslationResponse] {
|
||||
translations := i18n.TransStore.GetAllTranslations()
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK))
|
||||
}
|
||||
|
||||
// ReloadTranslationsResponse returns response after reloading translations
|
||||
func (s *LangService) ReloadTranslationsResponse(c fiber.Ctx) response.Response[map[string]string] {
|
||||
err := s.ReloadTranslations()
|
||||
if err != nil {
|
||||
return response.Make[map[string]string](c, fiber.StatusInternalServerError, nil, nil, i18n.T_(c, Message_LangsNotLoaded))
|
||||
}
|
||||
result := map[string]string{"status": "success"}
|
||||
return response.Make(c, fiber.StatusOK, nullable.GetNil(result), nil, i18n.T_(c, Message_LangsLoaded))
|
||||
}
|
||||
|
||||
// GetDefaultLanguage returns the default language
|
||||
func (s *LangService) GetDefaultLanguage() (*view.Language, error) {
|
||||
return s.repo.GetDefault()
|
||||
}
|
||||
|
||||
// GetLanguageByISOCode returns a language by its ISO code
|
||||
func (s *LangService) GetLanguageByISOCode(isoCode string) (*view.Language, error) {
|
||||
return s.repo.GetByISOCode(isoCode)
|
||||
}
|
||||
|
||||
// GetLanguageByISOCode returns a language by its ISO code
|
||||
func (s *LangService) GetLanguageById(id uint) (*view.Language, error) {
|
||||
return s.repo.GetById(id)
|
||||
}
|
||||
|
||||
func init() {
|
||||
LangSrv = &LangService{
|
||||
repo: *langs_repo.New(),
|
||||
}
|
||||
}
|
||||
335
app/service/repoService/repo.go
Normal file
335
app/service/repoService/repo.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package repoService
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/db"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// type
|
||||
type RepoService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New() *RepoService {
|
||||
return &RepoService{
|
||||
db: db.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RepoService) GetRepositoriesForUser(userID uint) ([]uint, error) {
|
||||
var repoIDs []uint
|
||||
|
||||
err := s.db.
|
||||
Table("customer_repo_accesses").
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("repo_id", &repoIDs).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
return repoIDs, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) UserHasAccessToRepo(userID uint, repoID uint) (bool, error) {
|
||||
var repositories []uint
|
||||
var err error
|
||||
|
||||
if repositories, err = s.GetRepositoriesForUser(userID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !slices.Contains(repositories, repoID) {
|
||||
return false, view.ErrInvalidRepoID
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Extract all repositories assigned to user with specific id
|
||||
func (s *RepoService) GetYearsForUser(userID uint, repoID uint) ([]uint, error) {
|
||||
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
years, err := s.GetYears(repoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
return years, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetYears(repo uint) ([]uint, error) {
|
||||
|
||||
var years []uint
|
||||
|
||||
query := `
|
||||
WITH bounds AS (
|
||||
SELECT
|
||||
MIN(to_timestamp(tt.created_unix)) AS min_ts,
|
||||
MAX(to_timestamp(tt.created_unix)) AS max_ts
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
WHERE i.repo_id = ?
|
||||
AND tt.deleted = false
|
||||
)
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM y.year_start)::int AS year
|
||||
FROM bounds
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
date_trunc('year', min_ts),
|
||||
date_trunc('year', max_ts),
|
||||
interval '1 year'
|
||||
) AS y(year_start)
|
||||
ORDER BY year
|
||||
`
|
||||
|
||||
err := db.Get().Raw(query, repo).Find(&years).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
|
||||
// Extract all repositories assigned to user with specific id
|
||||
func (s *RepoService) GetQuartersForUser(userID uint, repoID uint, year uint) ([]model.QuarterData, error) {
|
||||
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := s.GetQuarters(repoID, year)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetQuarters(repo uint, year uint) ([]model.QuarterData, error) {
|
||||
var quarters []model.QuarterData
|
||||
|
||||
query := `
|
||||
WITH quarters AS (
|
||||
SELECT
|
||||
make_date(?::int, 1, 1) + (q * interval '3 months') AS quarter_start,
|
||||
q + 1 AS quarter
|
||||
FROM generate_series(0, 3) AS q
|
||||
),
|
||||
data AS (
|
||||
SELECT
|
||||
EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) AS quarter,
|
||||
SUM(tt.time) / 3600 AS time
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
JOIN repository r ON i.repo_id = r.id
|
||||
WHERE
|
||||
EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND r.id = ?
|
||||
AND tt.deleted = false
|
||||
GROUP BY EXTRACT(QUARTER FROM to_timestamp(tt.created_unix))
|
||||
)
|
||||
SELECT
|
||||
COALESCE(d.time, 0) AS time,
|
||||
CONCAT(EXTRACT(YEAR FROM q.quarter_start)::int, '_Q', q.quarter) AS quarter
|
||||
FROM quarters q
|
||||
LEFT JOIN data d ON d.quarter = q.quarter
|
||||
ORDER BY q.quarter
|
||||
`
|
||||
|
||||
err := db.Get().
|
||||
Raw(query, year, year, repo).
|
||||
Find(&quarters).
|
||||
Error
|
||||
if err != nil {
|
||||
fmt.Printf("err: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return quarters, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetTotalTimeForQuarter(repo uint, year uint, quarter uint) (float64, error) {
|
||||
var total float64
|
||||
|
||||
query := `
|
||||
SELECT COALESCE(SUM(tt.time) / 3600, 0) AS total_time
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
WHERE i.repo_id = ?
|
||||
AND EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND tt.deleted = false
|
||||
`
|
||||
|
||||
err := db.Get().Raw(query, repo, year, quarter).Row().Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetTimeTracked(repo uint, year uint, quarter uint, step string) ([]model.DayData, error) {
|
||||
var days []model.DayData
|
||||
|
||||
// Calculate quarter start and end dates
|
||||
quarterStartMonth := (quarter-1)*3 + 1
|
||||
quarterStart := fmt.Sprintf("%d-%02d-01", year, quarterStartMonth)
|
||||
var quarterEnd string
|
||||
switch quarter {
|
||||
case 1:
|
||||
quarterEnd = fmt.Sprintf("%d-03-31", year)
|
||||
case 2:
|
||||
quarterEnd = fmt.Sprintf("%d-06-30", year)
|
||||
case 3:
|
||||
quarterEnd = fmt.Sprintf("%d-09-30", year)
|
||||
default:
|
||||
quarterEnd = fmt.Sprintf("%d-12-31", year)
|
||||
}
|
||||
|
||||
var bucketExpr string
|
||||
var seriesInterval string
|
||||
var seriesStart string
|
||||
var seriesEnd string
|
||||
|
||||
switch step {
|
||||
case "day":
|
||||
bucketExpr = "DATE(to_timestamp(tt.created_unix))"
|
||||
seriesInterval = "1 day"
|
||||
seriesStart = "p.start_date"
|
||||
seriesEnd = "p.end_date"
|
||||
|
||||
case "week":
|
||||
bucketExpr = `
|
||||
(p.start_date +
|
||||
((DATE(to_timestamp(tt.created_unix)) - p.start_date) / 7) * 7
|
||||
)::date`
|
||||
seriesInterval = "7 days"
|
||||
seriesStart = "p.start_date"
|
||||
seriesEnd = "p.end_date"
|
||||
|
||||
case "month":
|
||||
bucketExpr = "date_trunc('month', to_timestamp(tt.created_unix))::date"
|
||||
seriesInterval = "1 month"
|
||||
seriesStart = "date_trunc('month', p.start_date)"
|
||||
seriesEnd = "date_trunc('month', p.end_date)"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH params AS (
|
||||
SELECT ?::date AS start_date, ?::date AS end_date
|
||||
),
|
||||
date_range AS (
|
||||
SELECT generate_series(
|
||||
%s,
|
||||
%s,
|
||||
interval '%s'
|
||||
)::date AS date
|
||||
FROM params p
|
||||
),
|
||||
data AS (
|
||||
SELECT
|
||||
%s AS date,
|
||||
SUM(tt.time) / 3600 AS time
|
||||
FROM tracked_time tt
|
||||
JOIN issue i ON i.id = tt.issue_id
|
||||
CROSS JOIN params p
|
||||
WHERE i.repo_id = ?
|
||||
AND to_timestamp(tt.created_unix) >= p.start_date
|
||||
AND to_timestamp(tt.created_unix) < p.end_date + interval '1 day'
|
||||
AND tt.deleted = false
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
TO_CHAR(dr.date, 'YYYY-MM-DD') AS date,
|
||||
COALESCE(d.time, 0) AS time
|
||||
FROM date_range dr
|
||||
LEFT JOIN data d ON d.date = dr.date
|
||||
ORDER BY dr.date
|
||||
`, seriesStart, seriesEnd, seriesInterval, bucketExpr)
|
||||
err := db.Get().
|
||||
Raw(query, quarterStart, quarterEnd, repo).
|
||||
Scan(&days).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return days, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetRepoData(repoIds []uint) ([]model.Repository, error) {
|
||||
var repos []model.Repository
|
||||
|
||||
err := db.Get().Model(model.Repository{}).Where("id = ?", repoIds).Find(&repos).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func (s *RepoService) GetIssuesForUser(
|
||||
userID uint,
|
||||
repoID uint,
|
||||
year uint,
|
||||
quarter uint,
|
||||
p pagination.Paging,
|
||||
) (*pagination.Found[view.IssueTimeSummary], error) {
|
||||
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetIssues(repoID, year, quarter, p)
|
||||
}
|
||||
|
||||
func (s *RepoService) GetIssues(
|
||||
repoId uint,
|
||||
year uint,
|
||||
quarter uint,
|
||||
p pagination.Paging,
|
||||
) (*pagination.Found[view.IssueTimeSummary], error) {
|
||||
|
||||
query := db.Get().Debug().
|
||||
Table("issue i").
|
||||
Select(`
|
||||
i.id AS issue_id,
|
||||
i.name AS issue_name,
|
||||
u.id AS user_id,
|
||||
upper(
|
||||
regexp_replace(
|
||||
regexp_replace(u.full_name, '(\y\w)\w*', '\1', 'g'),
|
||||
'(\w)', '\1.', 'g'
|
||||
)
|
||||
) AS initials,
|
||||
to_timestamp(tt.created_unix)::date AS created_date,
|
||||
ROUND(SUM(tt.time) / 3600.0, 2) AS total_hours_spent
|
||||
`).
|
||||
Joins(`JOIN tracked_time tt ON tt.issue_id = i.id`).
|
||||
Joins(`JOIN "user" u ON u.id = tt.user_id`).
|
||||
Where("i.repo_id = ?", repoId).
|
||||
Where(`
|
||||
EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
|
||||
AND EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) = ?
|
||||
`, year, quarter).
|
||||
Group(`
|
||||
i.id,
|
||||
i.name,
|
||||
u.id,
|
||||
u.full_name,
|
||||
created_date
|
||||
`).
|
||||
Order("created_date")
|
||||
|
||||
result, err := pagination.Paginate[view.IssueTimeSummary](p, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
35
app/templ/emails/emailAdminNotification.templ
Normal file
35
app/templ/emails/emailAdminNotification.templ
Normal file
@@ -0,0 +1,35 @@
|
||||
package emails
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
)
|
||||
|
||||
templ EmailAdminNotificationWrapper(data view.EmailLayout[view.EmailAdminNotificationData]) {
|
||||
@layout.Base( i18n.T___(data.LangID, "email.email_admin_notification_title")) {
|
||||
<div class="container">
|
||||
<div class="email-wrapper">
|
||||
<div class="email-header">
|
||||
<h1>New User Registration</h1>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p>Hello Administrator,</p>
|
||||
<p>A new user has completed their registration and requires repository access.</p>
|
||||
<div class="info-box">
|
||||
<strong>User Details:</strong>
|
||||
<p><strong>Name:</strong> { data.Data.UserName } </p>
|
||||
<p><strong>Email:</strong> { data.Data.UserEmail } </p>
|
||||
</div>
|
||||
<p>Please assign the appropriate repositories to this user in the admin panel.</p>
|
||||
<div style="text-align: center;">
|
||||
<a href="{ data.Data.BaseURL }/admin/users" class="button">Go to Admin Panel</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>© 2024 Gitea Manager. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
90
app/templ/emails/emailAdminNotification_templ.go
Normal file
90
app/templ/emails/emailAdminNotification_templ.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package emails
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
func EmailAdminNotificationWrapper(data view.EmailLayout[view.EmailAdminNotificationData]) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container\"><div class=\"email-wrapper\"><div class=\"email-header\"><h1>New User Registration</h1></div><div class=\"email-body\"><p>Hello Administrator,</p><p>A new user has completed their registration and requires repository access.</p><div class=\"info-box\"><strong>User Details:</strong><p><strong>Name:</strong> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.UserName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailAdminNotification.templ`, Line: 21, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</p><p><strong>Email:</strong> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.UserEmail)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailAdminNotification.templ`, Line: 22, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p></div><p>Please assign the appropriate repositories to this user in the admin panel.</p><div style=\"text-align: center;\"><a href=\"{ data.Data.BaseURL }/admin/users\" class=\"button\">Go to Admin Panel</a></div></div><div class=\"email-footer\"><p>© 2024 Gitea Manager. All rights reserved.</p></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layout.Base(i18n.T___(data.LangID, "email.email_admin_notification_title")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
34
app/templ/emails/emailPasswordReset.templ
Normal file
34
app/templ/emails/emailPasswordReset.templ
Normal file
@@ -0,0 +1,34 @@
|
||||
package emails
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
)
|
||||
|
||||
templ EmailPasswordResetWrapper(data view.EmailLayout[view.EmailPasswordResetData]) {
|
||||
@layout.Base( i18n.T___(data.LangID, "email.email_password_reset_title")) {
|
||||
<div class="container">
|
||||
<div class="email-wrapper">
|
||||
<div class="email-header">
|
||||
<h1> { i18n.T___(data.LangID, "email.email_greeting") } </h1>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p> { i18n.T___(data.LangID, "email.email_password_reset_message1") } </p>
|
||||
<div style="text-align: center;">
|
||||
<a href= { data.Data.ResetURL } class="button"> { i18n.T___(data.LangID, "email.email_reset_button") } </a>
|
||||
</div>
|
||||
<p> { i18n.T___(data.LangID, "email.email_or_copy") }</p>
|
||||
<div class="link-container"> { data.Data.ResetURL } </div>
|
||||
<div class="warning">
|
||||
<strong> { i18n.T___(data.LangID, "email.email_warning_title") } </strong> { i18n.T___(data.LangID, "email.email_password_reset_warning") }
|
||||
</div>
|
||||
<p> { i18n.T___(data.LangID, "email.email_ignore_reset") } </p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>© 2024 Gitea Manager. { i18n.T___(data.LangID, "email.email_footer") } </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
194
app/templ/emails/emailPasswordReset_templ.go
Normal file
194
app/templ/emails/emailPasswordReset_templ.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package emails
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
func EmailPasswordResetWrapper(data view.EmailLayout[view.EmailPasswordResetData]) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container\"><div class=\"email-wrapper\"><div class=\"email-header\"><h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_greeting"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 14, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1></div><div class=\"email-body\"><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_password_reset_message1"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 17, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p><div style=\"text-align: center;\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(data.Data.ResetURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 19, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"button\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_reset_button"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 19, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a></div><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_or_copy"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 21, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p><div class=\"link-container\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.ResetURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 22, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"warning\"><strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_warning_title"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 24, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</strong> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_password_reset_warning"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 24, Col: 161}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_ignore_reset"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 26, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div><div class=\"email-footer\"><p>© 2024 Gitea Manager. ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_footer"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailPasswordReset.templ`, Line: 29, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layout.Base(i18n.T___(data.LangID, "email.email_password_reset_title")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
34
app/templ/emails/emailVerification.templ
Normal file
34
app/templ/emails/emailVerification.templ
Normal file
@@ -0,0 +1,34 @@
|
||||
package emails
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
)
|
||||
|
||||
templ EmailVerificationWrapper(data view.EmailLayout[view.EmailVerificationData]) {
|
||||
@layout.Base( i18n.T___(data.LangID, "email.email_verification_title")) {
|
||||
<div class="container">
|
||||
<div class="email-wrapper">
|
||||
<div class="email-header">
|
||||
<h1>{ i18n.T___(data.LangID, "email.email_verification_title") }</h1>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
<p>{ i18n.T___(data.LangID, "email.email_greeting") }</p>
|
||||
<p>{ i18n.T___(data.LangID, "email.email_verification_message1") }</p>
|
||||
<div style="text-align: center;">
|
||||
<a href={ data.Data.VerificationURL } class="button">{ i18n.T___(data.LangID, "email.email_verify_button") }</a>
|
||||
</div>
|
||||
<p>{ i18n.T___(data.LangID, "email.email_or_copy") }</p>
|
||||
<div class="link-container">{ data.Data.VerificationURL }</div>
|
||||
<p><strong>{ i18n.T___(data.LangID, "email.email_verification_note") }</strong></p>
|
||||
<p>{ i18n.T___(data.LangID, "email.email_ignore") }</p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<p>© 2024 Gitea Manager. { i18n.T___(data.LangID, "email.email_footer")}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
194
app/templ/emails/emailVerification_templ.go
Normal file
194
app/templ/emails/emailVerification_templ.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package emails
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"git.ma-al.com/goc_marek/timetracker/app/templ/layout"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/view"
|
||||
)
|
||||
|
||||
func EmailVerificationWrapper(data view.EmailLayout[view.EmailVerificationData]) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container\"><div class=\"email-wrapper\"><div class=\"email-header\"><h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verification_title"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 14, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1></div><div class=\"email-body\"><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_greeting"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 17, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verification_message1"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 18, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</p><div style=\"text-align: center;\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(data.Data.VerificationURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 20, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"button\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verify_button"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 20, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a></div><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_or_copy"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 22, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p><div class=\"link-container\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data.VerificationURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 23, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><p><strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_verification_note"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 24, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</strong></p><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_ignore"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 25, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div><div class=\"email-footer\"><p>© 2024 Gitea Manager. ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(i18n.T___(data.LangID, "email.email_footer"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/emails/emailVerification.templ`, Line: 28, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layout.Base(i18n.T___(data.LangID, "email.email_verification_title")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
83
app/templ/layout/base.templ
Normal file
83
app/templ/layout/base.templ
Normal file
@@ -0,0 +1,83 @@
|
||||
package layout
|
||||
|
||||
templ Base(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@head(title)
|
||||
<body style="margin:0;padding:0;background:#f4f4f4;">
|
||||
{ children... }
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ head(title string) {
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ title }</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.email-wrapper {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #dddddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #4A90E2;
|
||||
color: #ffffff;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.email-body {
|
||||
padding: 30px;
|
||||
}
|
||||
.email-body p {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #4A90E2;
|
||||
color: #ffffff;
|
||||
padding: 14px 28px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f8f8f8;
|
||||
padding: 20px 30px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
.link-container {
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
background-color: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
}
|
||||
98
app/templ/layout/base_templ.go
Normal file
98
app/templ/layout/base_templ.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package layout
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func Base(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = head(title).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body style=\"margin:0;padding:0;background:#f4f4f4;\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func head(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `app/templ/layout/base.templ`, Line: 17, Col: 16}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</title><style>\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;\n line-height: 1.6;\n color: #333333;\n margin: 0;\n padding: 0;\n background-color: #f4f4f4;\n }\n .container {\n max-width: 600px;\n margin: 0 auto;\n padding: 20px;\n }\n .email-wrapper {\n background-color: #ffffff;\n border-radius: 8px;\n border: 2px solid #dddddd;\n overflow: hidden;\n }\n .email-header {\n background-color: #4A90E2;\n color: #ffffff;\n padding: 30px;\n text-align: center;\n }\n .email-header h1 {\n margin: 0;\n font-size: 24px;\n font-weight: 600;\n }\n .email-body {\n padding: 30px;\n }\n .email-body p {\n margin: 0 0 16px 0;\n }\n .button {\n display: inline-block;\n background-color: #4A90E2;\n color: #ffffff;\n padding: 14px 28px;\n text-decoration: none;\n border-radius: 6px;\n font-weight: 500;\n margin: 20px 0;\n }\n .email-footer {\n background-color: #f8f8f8;\n padding: 20px 30px;\n text-align: center;\n font-size: 12px;\n color: #666666;\n }\n .link-container {\n word-break: break-all;\n font-size: 12px;\n color: #666666;\n background-color: #f8f8f8;\n padding: 10px;\n border-radius: 4px;\n margin: 10px 0;\n }\n </style></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
4
app/utils/const_data/consts.go
Normal file
4
app/utils/const_data/consts.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package constdata
|
||||
|
||||
// PASSWORD_VALIDATION_REGEX is used by the frontend (JavaScript supports lookaheads).
|
||||
const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$`
|
||||
237
app/utils/i18n/i18n.go
Normal file
237
app/utils/i18n/i18n.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type I18nTranslation string
|
||||
|
||||
type TranslationResponse map[uint]map[string]map[string]map[string]string
|
||||
|
||||
var TransStore = newTranslationsStore()
|
||||
|
||||
var (
|
||||
ErrLangIsoEmpty = errors.New("lang_id_empty")
|
||||
ErrScopeEmpty = errors.New("scope_empty")
|
||||
ErrComponentEmpty = errors.New("component_empty")
|
||||
ErrKeyEmpty = errors.New("key_empty")
|
||||
|
||||
ErrLangIsoNotFoundInCache = errors.New("lang_id_not_in_cache")
|
||||
ErrScopeNotFoundInCache = errors.New("scope_not_in_cache")
|
||||
ErrComponentNotFoundInCache = errors.New("component_not_in_cache")
|
||||
ErrKeyNotFoundInCache = errors.New("key_invalid_in_cache")
|
||||
)
|
||||
|
||||
type TranslationsStore struct {
|
||||
mutex sync.RWMutex
|
||||
cache TranslationResponse
|
||||
}
|
||||
|
||||
func newTranslationsStore() *TranslationsStore {
|
||||
service := &TranslationsStore{
|
||||
cache: make(TranslationResponse),
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *TranslationsStore) Get(langID uint, scope string, component string, key string) (string, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
if langID == 0 {
|
||||
return "lang_id_empty", ErrLangIsoEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID]; !ok {
|
||||
return fmt.Sprintf("lang_id_not_in_cache: %d", langID), ErrLangIsoNotFoundInCache
|
||||
}
|
||||
|
||||
if scope == "" {
|
||||
return "scope_empty", ErrScopeEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope]; !ok {
|
||||
return fmt.Sprintf("scope_not_in_cache: %s", scope), ErrScopeNotFoundInCache
|
||||
}
|
||||
|
||||
if component == "" {
|
||||
return "component_empty", ErrComponentEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope][component]; !ok {
|
||||
return fmt.Sprintf("component_not_in_cache: %s", component), ErrComponentNotFoundInCache
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
return "key_empty", ErrKeyEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope][component][key]; !ok {
|
||||
return fmt.Sprintf("key_invalid_in_cache: %s", key), ErrKeyNotFoundInCache
|
||||
}
|
||||
|
||||
return s.cache[langID][scope][component][key], nil
|
||||
}
|
||||
func (s *TranslationsStore) GetTranslations(langID uint, scope string, components []string) (*TranslationResponse, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
if langID == 0 {
|
||||
resp := make(TranslationResponse)
|
||||
for k, v := range s.cache {
|
||||
resp[k] = v
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID]; !ok {
|
||||
return nil, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
tr := make(TranslationResponse)
|
||||
|
||||
if scope == "" {
|
||||
tr[langID] = s.cache[langID]
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope]; !ok {
|
||||
return nil, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
tr[langID] = make(map[string]map[string]map[string]string)
|
||||
if len(components) <= 0 {
|
||||
tr[langID][scope] = s.cache[langID][scope]
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
tr[langID][scope] = make(map[string]map[string]string)
|
||||
|
||||
var invalidComponents []string
|
||||
for _, component := range components {
|
||||
if _, ok := s.cache[langID][scope][component]; !ok {
|
||||
invalidComponents = append(invalidComponents, component)
|
||||
continue
|
||||
}
|
||||
tr[langID][scope][component] = s.cache[langID][scope][component]
|
||||
}
|
||||
if len(invalidComponents) > 0 {
|
||||
return &tr, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
// GetAllTranslations returns all translations from the cache
|
||||
func (s *TranslationsStore) GetAllTranslations() *TranslationResponse {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
resp := make(TranslationResponse)
|
||||
for k, v := range s.cache {
|
||||
resp[k] = v
|
||||
}
|
||||
return &resp
|
||||
}
|
||||
|
||||
func (s *TranslationsStore) LoadTranslations(translations []model.Translation) error {
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
s.cache = make(TranslationResponse)
|
||||
for _, t := range translations {
|
||||
lang := uint(t.LangID)
|
||||
scp := t.Scope.Name
|
||||
cmp := t.Component.Name
|
||||
data := ""
|
||||
if t.Data != nil {
|
||||
data = *t.Data
|
||||
}
|
||||
|
||||
if _, ok := s.cache[lang]; !ok {
|
||||
s.cache[lang] = make(map[string]map[string]map[string]string)
|
||||
}
|
||||
if _, ok := s.cache[lang][scp]; !ok {
|
||||
s.cache[lang][scp] = make(map[string]map[string]string)
|
||||
}
|
||||
if _, ok := s.cache[lang][scp][cmp]; !ok {
|
||||
s.cache[lang][scp][cmp] = make(map[string]string)
|
||||
}
|
||||
s.cache[lang][scp][cmp][t.Key] = data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadTranslations reloads translations from the database
|
||||
func (s *TranslationsStore) ReloadTranslations(translations []model.Translation) error {
|
||||
return s.LoadTranslations(translations)
|
||||
}
|
||||
|
||||
// T_ is meant to be used to translate error messages and other system communicates.
|
||||
func T_[T ~string](c fiber.Ctx, key T, params ...interface{}) string {
|
||||
if langID, ok := c.Locals("langID").(uint); ok {
|
||||
parts := strings.Split(string(key), ".")
|
||||
|
||||
if len(parts) >= 2 {
|
||||
if trans, err := TransStore.Get(langID, "Backend", parts[0], strings.Join(parts[1:], ".")); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
} else {
|
||||
if trans, err := TransStore.Get(langID, "Backend", "be", parts[0]); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// T___ works exactly the same as T_ but uses just language ID instead of the whole context
|
||||
func T___[T ~string](langId uint, key T, params ...interface{}) string {
|
||||
parts := strings.Split(string(key), ".")
|
||||
|
||||
if len(parts) >= 2 {
|
||||
if trans, err := TransStore.Get(langId, "Backend", parts[0], strings.Join(parts[1:], ".")); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
} else {
|
||||
if trans, err := TransStore.Get(langId, "Backend", "be", parts[0]); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
}
|
||||
|
||||
return string(key)
|
||||
}
|
||||
|
||||
func Format(text string, params ...interface{}) string {
|
||||
text = fmt.Sprintf(text, params...)
|
||||
return text
|
||||
}
|
||||
|
||||
// T__ wraps T_ adding a conversion from string to template.HTML
|
||||
func T__(c fiber.Ctx, key string, params ...interface{}) template.HTML {
|
||||
return template.HTML(T_(c, key, params...))
|
||||
}
|
||||
|
||||
// MapKeyOnTranslationMap is a helper function to map keys on translation map
|
||||
// this is used to map keys on translation map
|
||||
//
|
||||
// example:
|
||||
// map := map[T]string{}
|
||||
// MapKeyOnTranslationMap(ctx, map, key1, key2, key3)
|
||||
func MapKeyOnTranslationMap[T ~string](c fiber.Ctx, m *map[T]string, key ...T) {
|
||||
if *m == nil {
|
||||
*m = make(map[T]string)
|
||||
}
|
||||
for _, k := range key {
|
||||
(*m)[k] = T_(c, string(k))
|
||||
}
|
||||
}
|
||||
74
app/utils/mapper/mapper.go
Normal file
74
app/utils/mapper/mapper.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package mapper
|
||||
|
||||
// Package mapper provides utilities to map fields from one struct to another
|
||||
// by matching field names (case-insensitive). Unmatched fields are left as
|
||||
// their zero values.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Map copies field values from src into dst by matching field names
|
||||
// (case-insensitive). Fields in dst that have no counterpart in src
|
||||
// are left at their zero value.
|
||||
//
|
||||
// Both dst and src must be pointers to structs.
|
||||
// Returns an error if the types do not satisfy those constraints.
|
||||
func Map(dst, src any) error {
|
||||
dstVal := reflect.ValueOf(dst)
|
||||
srcVal := reflect.ValueOf(src)
|
||||
|
||||
if dstVal.Kind() != reflect.Ptr || dstVal.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("mapper: dst must be a pointer to a struct, got %T", dst)
|
||||
}
|
||||
if srcVal.Kind() != reflect.Ptr || srcVal.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("mapper: src must be a pointer to a struct, got %T", src)
|
||||
}
|
||||
|
||||
dstElem := dstVal.Elem()
|
||||
srcElem := srcVal.Elem()
|
||||
|
||||
// Build a lookup map of src fields: lowercase name -> field value
|
||||
srcFields := make(map[string]reflect.Value)
|
||||
for i := 0; i < srcElem.NumField(); i++ {
|
||||
f := srcElem.Type().Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
srcFields[strings.ToLower(f.Name)] = srcElem.Field(i)
|
||||
}
|
||||
|
||||
// Iterate over dst fields and copy matching src values
|
||||
for i := 0; i < dstElem.NumField(); i++ {
|
||||
dstField := dstElem.Field(i)
|
||||
dstType := dstElem.Type().Field(i)
|
||||
|
||||
if !dstType.IsExported() || !dstField.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
srcField, ok := srcFields[strings.ToLower(dstType.Name)]
|
||||
if !ok {
|
||||
// No matching src field – leave zero value in place
|
||||
continue
|
||||
}
|
||||
|
||||
if srcField.Type().AssignableTo(dstField.Type()) {
|
||||
dstField.Set(srcField)
|
||||
} else if srcField.Type().ConvertibleTo(dstField.Type()) {
|
||||
dstField.Set(srcField.Convert(dstField.Type()))
|
||||
}
|
||||
// If neither assignable nor convertible, the dst field keeps its zero value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustMap is like Map but panics on error.
|
||||
func MustMap(dst, src any) {
|
||||
if err := Map(dst, src); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
78
app/utils/mapper/mapper_test.go
Normal file
78
app/utils/mapper/mapper_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package mapper_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/mapper"
|
||||
)
|
||||
|
||||
// --- example structs ---
|
||||
|
||||
type UserInput struct {
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
}
|
||||
|
||||
type UserRecord struct {
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
CreatedAt string // not in src → stays ""
|
||||
Active bool // not in src → stays false
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
func TestMap_MatchingFields(t *testing.T) {
|
||||
src := &UserInput{Name: "Alice", Email: "alice@example.com", Age: 30}
|
||||
dst := &UserRecord{}
|
||||
|
||||
if err := mapper.Map(dst, src); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if dst.Name != "Alice" {
|
||||
t.Errorf("Name: want Alice, got %s", dst.Name)
|
||||
}
|
||||
if dst.Email != "alice@example.com" {
|
||||
t.Errorf("Email: want alice@example.com, got %s", dst.Email)
|
||||
}
|
||||
if dst.Age != 30 {
|
||||
t.Errorf("Age: want 30, got %d", dst.Age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_UnmatchedFieldsAreZero(t *testing.T) {
|
||||
src := &UserInput{Name: "Bob"}
|
||||
dst := &UserRecord{}
|
||||
|
||||
mapper.MustMap(dst, src)
|
||||
|
||||
if dst.CreatedAt != "" {
|
||||
t.Errorf("CreatedAt: expected empty string, got %q", dst.CreatedAt)
|
||||
}
|
||||
if dst.Active != false {
|
||||
t.Errorf("Active: expected false, got %v", dst.Active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_TypeConversion(t *testing.T) {
|
||||
type Src struct{ Score int32 }
|
||||
type Dst struct{ Score int64 }
|
||||
|
||||
src := &Src{Score: 99}
|
||||
dst := &Dst{}
|
||||
|
||||
mapper.MustMap(dst, src)
|
||||
|
||||
if dst.Score != 99 {
|
||||
t.Errorf("Score: want 99, got %d", dst.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_InvalidInput(t *testing.T) {
|
||||
if err := mapper.Map("not a struct", 42); err == nil {
|
||||
t.Error("expected error for non-struct inputs")
|
||||
}
|
||||
}
|
||||
6
app/utils/nullable/nullable.go
Normal file
6
app/utils/nullable/nullable.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package nullable
|
||||
|
||||
//go:fix inline
|
||||
func GetNil[T any](in T) *T {
|
||||
return new(in)
|
||||
}
|
||||
63
app/utils/pagination/pagination.go
Normal file
63
app/utils/pagination/pagination.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Paging struct {
|
||||
Page uint `json:"page_number" example:"5"`
|
||||
Elements uint `json:"elements_per_page" example:"30"`
|
||||
}
|
||||
|
||||
func (p Paging) Offset() int {
|
||||
return int(p.Elements) * int(p.Page-1)
|
||||
}
|
||||
|
||||
func (p Paging) Limit() int {
|
||||
return int(p.Elements)
|
||||
}
|
||||
|
||||
type Found[T any] struct {
|
||||
Items []T `json:"items,omitempty"`
|
||||
Count uint `json:"items_count" example:"56"`
|
||||
}
|
||||
|
||||
func Paginate[T any](paging Paging, stmt *gorm.DB) (Found[T], error) {
|
||||
var items []T
|
||||
var count int64
|
||||
|
||||
base := stmt.Session(&gorm.Session{})
|
||||
|
||||
countDB := stmt.Session(&gorm.Session{
|
||||
NewDB: true, // critical: do NOT reuse statement
|
||||
})
|
||||
|
||||
if err := countDB.
|
||||
Table("(?) as sub", base).
|
||||
Count(&count).Error; err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
err := base.
|
||||
Offset(paging.Offset()).
|
||||
Limit(paging.Limit()).
|
||||
Find(&items).
|
||||
Error
|
||||
if err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
return Found[T]{
|
||||
Items: items,
|
||||
Count: uint(count),
|
||||
}, err
|
||||
}
|
||||
|
||||
func ParsePagination(c *fiber.Ctx) Paging {
|
||||
pageNum, _ := strconv.ParseInt((*c).Query("p", "1"), 10, 64)
|
||||
pageSize, _ := strconv.ParseInt((*c).Query("elems", "10"), 10, 64)
|
||||
return Paging{Page: uint(pageNum), Elements: uint(pageSize)}
|
||||
}
|
||||
10
app/utils/response/messages.go
Normal file
10
app/utils/response/messages.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package response
|
||||
|
||||
import "git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
|
||||
type ResponseMessage i18n.I18nTranslation
|
||||
|
||||
const (
|
||||
Message_OK ResponseMessage = "message_ok"
|
||||
Message_NOK ResponseMessage = "message_nok"
|
||||
)
|
||||
18
app/utils/response/response.go
Normal file
18
app/utils/response/response.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
type Response[T any] struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Items *T `json:"items,omitempty"`
|
||||
Count *int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
func Make[T any](c fiber.Ctx, status int, items *T, count *int, message string) Response[T] {
|
||||
c.Status(status)
|
||||
return Response[T]{
|
||||
Message: message,
|
||||
Items: items,
|
||||
Count: count,
|
||||
}
|
||||
}
|
||||
136
app/utils/version/version.go
Normal file
136
app/utils/version/version.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// Version info populated at build time
|
||||
var (
|
||||
Version string // Git tag or commit hash
|
||||
Commit string // Short commit hash
|
||||
BuildDate string // Build timestamp
|
||||
)
|
||||
|
||||
// Info returns version information
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
}
|
||||
|
||||
// GetInfo returns version information
|
||||
func GetInfo() Info {
|
||||
v := Info{
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
BuildDate: BuildDate,
|
||||
}
|
||||
|
||||
// If not set during build, try to get from git
|
||||
if v.Version == "" || v.Version == "unknown" || v.Version == "(devel)" {
|
||||
if gitVersion, gitCommit := getGitInfo(); gitVersion != "" {
|
||||
v.Version = gitVersion
|
||||
v.Commit = gitCommit
|
||||
}
|
||||
}
|
||||
|
||||
// If build date not set, use current time
|
||||
if v.BuildDate == "" {
|
||||
v.BuildDate = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// getGitInfo returns the latest tag or short commit hash and the commit hash
|
||||
func getGitInfo() (string, string) {
|
||||
// Get the current working directory
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Open the git repository
|
||||
repo, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Get the HEAD reference
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
commitHash := head.Hash().String()[:7]
|
||||
|
||||
// Get all tags
|
||||
tagIter, err := repo.Tags()
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// Get the commit for HEAD
|
||||
commit, err := repo.CommitObject(head.Hash())
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// Build ancestry map
|
||||
ancestry := make(map[string]bool)
|
||||
c := commit
|
||||
for c != nil {
|
||||
ancestry[c.Hash.String()] = true
|
||||
c, _ = c.Parent(0)
|
||||
}
|
||||
|
||||
// Find the most recent tag that's an ancestor of HEAD
|
||||
var latestTag string
|
||||
err = tagIter.ForEach(func(ref *plumbing.Reference) error {
|
||||
// Get the target commit
|
||||
targetHash := ref.Hash()
|
||||
|
||||
// Get the target commit
|
||||
targetCommit, err := repo.CommitObject(targetHash)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this tag is an ancestor of HEAD
|
||||
checkCommit := targetCommit
|
||||
for checkCommit != nil {
|
||||
if ancestry[checkCommit.Hash.String()] {
|
||||
// Extract tag name (remove refs/tags/ prefix)
|
||||
tagName := strings.TrimPrefix(ref.Name().String(), "refs/tags/")
|
||||
if latestTag == "" || tagName > latestTag {
|
||||
latestTag = tagName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
checkCommit, _ = checkCommit.Parent(0)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
if latestTag != "" {
|
||||
return latestTag, commitHash
|
||||
}
|
||||
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// String returns a formatted version string
|
||||
func String() string {
|
||||
info := GetInfo()
|
||||
return fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s\n", info.Version, info.Commit, info.BuildDate)
|
||||
}
|
||||
20
app/view/emails.go
Normal file
20
app/view/emails.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package view
|
||||
|
||||
type EmailLayout[T any] struct {
|
||||
LangID uint
|
||||
Data T
|
||||
}
|
||||
|
||||
type EmailVerificationData struct {
|
||||
VerificationURL string
|
||||
}
|
||||
|
||||
type EmailAdminNotificationData struct {
|
||||
UserName string
|
||||
UserEmail string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type EmailPasswordResetData struct {
|
||||
ResetURL string
|
||||
}
|
||||
166
app/view/errors.go
Normal file
166
app/view/errors.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
// Typed errors for request validation and authentication
|
||||
ErrInvalidBody = errors.New("invalid request body")
|
||||
ErrNotAuthenticated = errors.New("not authenticated")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrTokenExpired = errors.New("token has expired")
|
||||
ErrTokenRequired = errors.New("token is required")
|
||||
|
||||
// Typed errors for logging in and registering
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrEmailNotVerified = errors.New("email not verified")
|
||||
ErrEmailExists = errors.New("email already exists")
|
||||
ErrFirstLastNameRequired = errors.New("first and last name is required")
|
||||
ErrEmailRequired = errors.New("email is required")
|
||||
ErrEmailPasswordRequired = errors.New("email and password are required")
|
||||
ErrRefreshTokenRequired = errors.New("refresh token is required")
|
||||
|
||||
// Typed errors for password reset
|
||||
ErrInvalidResetToken = errors.New("invalid reset token")
|
||||
ErrResetTokenExpired = errors.New("reset token has expired")
|
||||
ErrPasswordsDoNotMatch = errors.New("passwords do not match")
|
||||
ErrInvalidPassword = errors.New("password must be at least 8 characters long and contain at least one lowercase letter, one uppercase letter, and one digit")
|
||||
ErrTokenPasswordRequired = errors.New("token and password are required")
|
||||
|
||||
// Typed errors for verification
|
||||
ErrInvalidVerificationToken = errors.New("invalid verification token")
|
||||
ErrVerificationTokenExpired = errors.New("verification token has expired")
|
||||
|
||||
// Typed errors for data extraction
|
||||
ErrBadRepoIDAttribute = errors.New("invalid repo id attribute")
|
||||
ErrBadYearAttribute = errors.New("invalid year attribute")
|
||||
ErrBadQuarterAttribute = errors.New("invalid quarter attribute")
|
||||
ErrBadPaging = errors.New("invalid paging")
|
||||
ErrInvalidRepoID = errors.New("repo not accessible")
|
||||
)
|
||||
|
||||
// Error represents an error with HTTP status code
|
||||
type Error struct {
|
||||
Err error
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewError creates a new typed error
|
||||
func NewError(err error, status int) *Error {
|
||||
return &Error{Err: err, Status: status}
|
||||
}
|
||||
|
||||
// GetErrorCode returns the error code string for HTTP response mapping
|
||||
func GetErrorCode(c fiber.Ctx, err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidBody):
|
||||
return i18n.T_(c, "error.err_invalid_body")
|
||||
case errors.Is(err, ErrInvalidCredentials):
|
||||
return i18n.T_(c, "error.err_invalid_credentials")
|
||||
case errors.Is(err, ErrNotAuthenticated):
|
||||
return i18n.T_(c, "error.err_not_authenticated")
|
||||
case errors.Is(err, ErrUserNotFound):
|
||||
return i18n.T_(c, "error.err_user_not_found")
|
||||
case errors.Is(err, ErrUserInactive):
|
||||
return i18n.T_(c, "error.err_user_inactive")
|
||||
case errors.Is(err, ErrEmailNotVerified):
|
||||
return i18n.T_(c, "error.err_email_not_verified")
|
||||
case errors.Is(err, ErrEmailExists):
|
||||
return i18n.T_(c, "error.err_email_exists")
|
||||
case errors.Is(err, ErrInvalidToken):
|
||||
return i18n.T_(c, "error.err_invalid_token")
|
||||
case errors.Is(err, ErrTokenExpired):
|
||||
return i18n.T_(c, "error.err_token_expired")
|
||||
case errors.Is(err, ErrFirstLastNameRequired):
|
||||
return i18n.T_(c, "error.err_first_last_name_required")
|
||||
case errors.Is(err, ErrEmailRequired):
|
||||
return i18n.T_(c, "error.err_email_required")
|
||||
case errors.Is(err, ErrEmailPasswordRequired):
|
||||
return i18n.T_(c, "error.err_email_password_required")
|
||||
case errors.Is(err, ErrTokenRequired):
|
||||
return i18n.T_(c, "error.err_token_required")
|
||||
case errors.Is(err, ErrRefreshTokenRequired):
|
||||
return i18n.T_(c, "error.err_refresh_token_required")
|
||||
|
||||
case errors.Is(err, ErrInvalidResetToken):
|
||||
return i18n.T_(c, "error.err_invalid_reset_token")
|
||||
case errors.Is(err, ErrResetTokenExpired):
|
||||
return i18n.T_(c, "error.err_reset_token_expired")
|
||||
case errors.Is(err, ErrPasswordsDoNotMatch):
|
||||
return i18n.T_(c, "error.err_passwords_do_not_match")
|
||||
case errors.Is(err, ErrInvalidPassword):
|
||||
return i18n.T_(c, "error.err_invalid_password")
|
||||
case errors.Is(err, ErrTokenPasswordRequired):
|
||||
return i18n.T_(c, "error.err_token_password_required")
|
||||
|
||||
case errors.Is(err, ErrInvalidVerificationToken):
|
||||
return i18n.T_(c, "error.err_invalid_verification_token")
|
||||
case errors.Is(err, ErrVerificationTokenExpired):
|
||||
return i18n.T_(c, "error.err_verification_token_expired")
|
||||
|
||||
case errors.Is(err, ErrBadRepoIDAttribute):
|
||||
return i18n.T_(c, "error.err_bad_repo_id_attribute")
|
||||
case errors.Is(err, ErrBadYearAttribute):
|
||||
return i18n.T_(c, "error.err_bad_year_attribute")
|
||||
case errors.Is(err, ErrBadQuarterAttribute):
|
||||
return i18n.T_(c, "error.err_bad_quarter_attribute")
|
||||
case errors.Is(err, ErrBadPaging):
|
||||
return i18n.T_(c, "error.err_bad_paging")
|
||||
case errors.Is(err, ErrInvalidRepoID):
|
||||
return i18n.T_(c, "error.err_invalid_repo_id")
|
||||
|
||||
default:
|
||||
return i18n.T_(c, "error.err_internal_server_error")
|
||||
}
|
||||
}
|
||||
|
||||
// GetErrorStatus returns the HTTP status code for the given error
|
||||
func GetErrorStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials),
|
||||
errors.Is(err, ErrNotAuthenticated),
|
||||
errors.Is(err, ErrInvalidToken),
|
||||
errors.Is(err, ErrTokenExpired):
|
||||
return fiber.StatusUnauthorized
|
||||
case errors.Is(err, ErrInvalidBody),
|
||||
errors.Is(err, ErrUserNotFound),
|
||||
errors.Is(err, ErrUserInactive),
|
||||
errors.Is(err, ErrEmailNotVerified),
|
||||
errors.Is(err, ErrFirstLastNameRequired),
|
||||
errors.Is(err, ErrEmailRequired),
|
||||
errors.Is(err, ErrEmailPasswordRequired),
|
||||
errors.Is(err, ErrTokenRequired),
|
||||
errors.Is(err, ErrRefreshTokenRequired),
|
||||
errors.Is(err, ErrPasswordsDoNotMatch),
|
||||
errors.Is(err, ErrTokenPasswordRequired),
|
||||
errors.Is(err, ErrInvalidResetToken),
|
||||
errors.Is(err, ErrResetTokenExpired),
|
||||
errors.Is(err, ErrInvalidVerificationToken),
|
||||
errors.Is(err, ErrVerificationTokenExpired),
|
||||
errors.Is(err, ErrInvalidPassword),
|
||||
errors.Is(err, ErrBadRepoIDAttribute),
|
||||
errors.Is(err, ErrBadYearAttribute),
|
||||
errors.Is(err, ErrBadQuarterAttribute),
|
||||
errors.Is(err, ErrBadPaging),
|
||||
errors.Is(err, ErrInvalidRepoID):
|
||||
return fiber.StatusBadRequest
|
||||
case errors.Is(err, ErrEmailExists):
|
||||
return fiber.StatusConflict
|
||||
default:
|
||||
return fiber.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
21
app/view/i18n.go
Normal file
21
app/view/i18n.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Language struct {
|
||||
ID uint64 `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ISOCode string `json:"iso_code"`
|
||||
LangCode string `json:"lang_code"`
|
||||
DateFormat string `json:"date_format"`
|
||||
DateFormatShort string `json:"date_format_short"`
|
||||
RTL bool `json:"rtl"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Active bool `json:"active"`
|
||||
Flag string `json:"flag"`
|
||||
}
|
||||
36
app/view/repo.go
Normal file
36
app/view/repo.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
|
||||
)
|
||||
|
||||
type RepositoryChartData struct {
|
||||
Years []uint
|
||||
Quarters []model.QuarterData
|
||||
QuartersJSON string
|
||||
Year uint
|
||||
}
|
||||
|
||||
type TimeTrackedData struct {
|
||||
RepoId uint
|
||||
Year uint
|
||||
Quarter uint
|
||||
Step string
|
||||
TotalTime float64
|
||||
DailyData []model.DayData
|
||||
DailyDataJSON string
|
||||
Years []uint
|
||||
IssueSummaries *pagination.Found[IssueTimeSummary]
|
||||
}
|
||||
|
||||
type IssueTimeSummary struct {
|
||||
IssueID uint `gorm:"column:issue_id"`
|
||||
IssueName string `gorm:"column:issue_name"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Initials string `gorm:"column:initials"`
|
||||
CreatedDate time.Time `gorm:"column:created_date"`
|
||||
TotalHoursSpent float64 `gorm:"column:total_hours_spent"`
|
||||
}
|
||||
Reference in New Issue
Block a user