Compare commits
10
Commits
841037013a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef12aaadee | ||
|
|
1782d2c5ac | ||
|
|
53478fa452 | ||
|
|
79dd76ecc7 | ||
|
|
602343fd9f | ||
|
|
3ca052640a | ||
|
|
926c578df8 | ||
|
|
31800eceda | ||
|
|
639baafe4a | ||
|
|
43011f0a77 |
@@ -0,0 +1,5 @@
|
|||||||
|
./CalendarReminder.Api/appsettings.json
|
||||||
|
./CalendarReminder.Api/appsettings.Development.json
|
||||||
|
.gitea
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Skopíruj tento súbor ako .env a vyplň hodnoty
|
||||||
|
# cp .env.example .env
|
||||||
|
|
||||||
|
# Docker Hub
|
||||||
|
DOCKERHUB_USERNAME=
|
||||||
|
WATCHTOWER_TOKEN=
|
||||||
|
|
||||||
|
# Databáza
|
||||||
|
DB_SERVER=192.168.1.68
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=calendarreminder
|
||||||
|
DB_USER=
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# JWT — minimálne 32 znakov, náhodný reťazec
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Email SMTP
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USER=
|
||||||
|
EMAIL_PASS=
|
||||||
|
EMAIL_SSL=false
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/calendarreminder-app:latest
|
||||||
|
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/calendarreminder-app:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/calendarreminder-app:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Trigger Watchtower deploy
|
||||||
|
run: |
|
||||||
|
curl -sf \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
|
||||||
|
http://${{ secrets.TRUENAS_IP }}:8085/v1/update
|
||||||
@@ -361,3 +361,8 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
# Secrets — NIKDY do gitu!
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Local dev settings (obsahuju heslá)
|
||||||
|
appsettings.Development.json
|
||||||
|
|||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using CalendarReminder.Api.Data;
|
||||||
|
using CalendarReminder.Api.Services;
|
||||||
|
using CalendarReminder.Domain.Entities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CalendarReminder.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/admin")]
|
||||||
|
public class AdminController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly EmailService _email;
|
||||||
|
|
||||||
|
public AdminController(AppDbContext db, EmailService email)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAdmin => User.FindFirst("isAdmin")?.Value == "true";
|
||||||
|
|
||||||
|
// ══════════════════════════════
|
||||||
|
// RODINY
|
||||||
|
// ══════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("families")]
|
||||||
|
public async Task<IActionResult> GetFamilies()
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
var families = await _db.Families
|
||||||
|
.Include(f => f.Users)
|
||||||
|
.Select(f => new { f.Id, f.Name, MemberCount = f.Users.Count })
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(families);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("families")]
|
||||||
|
public async Task<IActionResult> CreateFamily([FromBody] FamilyRequest req)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
if (await _db.Families.AnyAsync(f => f.Name == req.Name))
|
||||||
|
return BadRequest("Rodina s týmto názvom už existuje.");
|
||||||
|
var family = new Family { Name = req.Name };
|
||||||
|
_db.Families.Add(family);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(new { family.Id, family.Name, MemberCount = 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("families/{id}")]
|
||||||
|
public async Task<IActionResult> UpdateFamily(int id, [FromBody] FamilyRequest req)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
var family = await _db.Families.FindAsync(id);
|
||||||
|
if (family is null) return NotFound();
|
||||||
|
if (await _db.Families.AnyAsync(f => f.Name == req.Name && f.Id != id))
|
||||||
|
return BadRequest("Názov je obsadený.");
|
||||||
|
family.Name = req.Name;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(family);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("families/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteFamily(int id)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
var family = await _db.Families.Include(f => f.Users).FirstOrDefaultAsync(f => f.Id == id);
|
||||||
|
if (family is null) return NotFound();
|
||||||
|
if (family.Users.Any()) return BadRequest("Rodina má členov. Najprv presun alebo odstraň používateľov.");
|
||||||
|
_db.Families.Remove(family);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════
|
||||||
|
// POUŽÍVATELIA
|
||||||
|
// ══════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("users")]
|
||||||
|
public async Task<IActionResult> GetUsers()
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
var users = await _db.Users
|
||||||
|
.Include(u => u.Family)
|
||||||
|
.Select(u => new {
|
||||||
|
u.Id, u.Username, u.Email, u.IsAdmin,
|
||||||
|
u.MustChangePassword, u.CreatedAt,
|
||||||
|
FamilyId = u.FamilyId,
|
||||||
|
FamilyName = u.Family != null ? u.Family.Name : null
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("users")]
|
||||||
|
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest req)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Email == req.Email))
|
||||||
|
return BadRequest("Email je už registrovaný.");
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Username == req.Username))
|
||||||
|
return BadRequest("Meno je obsadené.");
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Username = req.Username,
|
||||||
|
Email = req.Email,
|
||||||
|
FamilyId = req.FamilyId,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("user"),
|
||||||
|
MustChangePassword = true
|
||||||
|
};
|
||||||
|
_db.Users.Add(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Email notifikácia
|
||||||
|
await _email.SendAsync(req.Email, req.Username,
|
||||||
|
"👋 Vítaj v Calendar Reminder — bol ti vytvorený účet",
|
||||||
|
$"""
|
||||||
|
<div style="font-family:Segoe UI,sans-serif;max-width:520px;margin:auto;background:#161b22;color:#e1e4e8;border-radius:10px;overflow:hidden">
|
||||||
|
<div style="background:#238636;padding:1.2rem 1.5rem">
|
||||||
|
<h2 style="margin:0;color:#fff;font-size:1.1rem">👋 Bol ti vytvorený účet</h2>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1.5rem">
|
||||||
|
<p style="margin:0 0 1rem">Ahoj <strong>{req.Username}</strong>, bol ti vytvorený účet v aplikácii <strong>Calendar Reminder</strong>.</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse">
|
||||||
|
<tr><td style="padding:0.4rem 0;color:#8b949e;width:80px">Meno</td><td style="padding:0.4rem 0;font-weight:600">{req.Username}</td></tr>
|
||||||
|
<tr><td style="padding:0.4rem 0;color:#8b949e">Email</td><td style="padding:0.4rem 0;font-weight:600">{req.Email}</td></tr>
|
||||||
|
<tr><td style="padding:0.4rem 0;color:#8b949e">Heslo</td><td style="padding:0.4rem 0;font-weight:600">user</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:1.25rem 0 0;color:#8b949e;font-size:0.88rem">Po prvom prihlásení ťa aplikácia vyzve na zmenu hesla.</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0.75rem 1.5rem;border-top:1px solid #30363d;font-size:0.78rem;color:#6e7681">Calendar Reminder</div>
|
||||||
|
</div>
|
||||||
|
""");
|
||||||
|
|
||||||
|
return Ok(new { user.Id, user.Username, user.Email, user.FamilyId, user.MustChangePassword });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("users/{id}")]
|
||||||
|
public async Task<IActionResult> UpdateUser(int id, [FromBody] UpdateUserRequest req)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
var user = await _db.Users.FindAsync(id);
|
||||||
|
if (user is null) return NotFound();
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Email == req.Email && u.Id != id))
|
||||||
|
return BadRequest("Email je obsadený.");
|
||||||
|
if (await _db.Users.AnyAsync(u => u.Username == req.Username && u.Id != id))
|
||||||
|
return BadRequest("Meno je obsadené.");
|
||||||
|
|
||||||
|
user.Username = req.Username;
|
||||||
|
user.Email = req.Email;
|
||||||
|
user.FamilyId = req.FamilyId;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return Ok(new { user.Id, user.Username, user.Email, user.FamilyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("users/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteUser(int id)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbid();
|
||||||
|
var myId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
|
||||||
|
if (id == myId) return BadRequest("Nemôžeš zmazať sám seba.");
|
||||||
|
var user = await _db.Users.FindAsync(id);
|
||||||
|
if (user is null) return NotFound();
|
||||||
|
_db.Users.Remove(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FamilyRequest(string Name);
|
||||||
|
public record CreateUserRequest(string Username, string Email, int? FamilyId);
|
||||||
|
public record UpdateUserRequest(string Username, string Email, int? FamilyId);
|
||||||
@@ -13,20 +13,21 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly TokenService _tokenService;
|
private readonly TokenService _tokenService;
|
||||||
|
private readonly EmailService _emailService;
|
||||||
|
|
||||||
public AuthController(AppDbContext db, TokenService tokenService)
|
public AuthController(AppDbContext db, TokenService tokenService, EmailService emailService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_tokenService = tokenService;
|
_tokenService = tokenService;
|
||||||
|
_emailService = emailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST api/auth/register
|
// POST api/auth/register (interný endpoint — nie je vystavený vo frontende)
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register(RegisterRequest req)
|
public async Task<IActionResult> Register(RegisterRequest req)
|
||||||
{
|
{
|
||||||
if (await _db.Users.AnyAsync(u => u.Email == req.Email))
|
if (await _db.Users.AnyAsync(u => u.Email == req.Email))
|
||||||
return BadRequest("Email je už registrovaný.");
|
return BadRequest("Email je už registrovaný.");
|
||||||
|
|
||||||
if (await _db.Users.AnyAsync(u => u.Username == req.Username))
|
if (await _db.Users.AnyAsync(u => u.Username == req.Username))
|
||||||
return BadRequest("Používateľské meno je obsadené.");
|
return BadRequest("Používateľské meno je obsadené.");
|
||||||
|
|
||||||
@@ -36,23 +37,102 @@ public class AuthController : ControllerBase
|
|||||||
Email = req.Email,
|
Email = req.Email,
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password)
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password)
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Users.Add(user);
|
_db.Users.Add(user);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var token = _tokenService.GenerateToken(user);
|
var token = _tokenService.GenerateToken(user);
|
||||||
return Ok(new AuthResponse(token, user.Username, user.Email));
|
return Ok(new AuthResponse(token, user.Username, user.Email, user.IsAdmin, user.MustChangePassword));
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST api/auth/login
|
// POST api/auth/login
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login(LoginRequest req)
|
public async Task<IActionResult> Login(LoginRequest req)
|
||||||
{
|
{
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == req.Email);
|
// Podpor prihlásenie cez email aj username
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u =>
|
||||||
|
u.Email == req.Email || u.Username == req.Email);
|
||||||
|
|
||||||
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||||
return Unauthorized("Nesprávny email alebo heslo.");
|
return Unauthorized("Nesprávny email/meno alebo heslo.");
|
||||||
|
|
||||||
var token = _tokenService.GenerateToken(user);
|
var token = _tokenService.GenerateToken(user);
|
||||||
return Ok(new AuthResponse(token, user.Username, user.Email));
|
return Ok(new AuthResponse(token, user.Username, user.Email, user.IsAdmin, user.MustChangePassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST api/auth/send-reset-code
|
||||||
|
// Vygeneruje 8-znakový kód a odošle ho emailom (pre budúce použitie)
|
||||||
|
[HttpPost("send-reset-code")]
|
||||||
|
public async Task<IActionResult> SendResetCode([FromBody] SendResetCodeRequest req)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == req.Email);
|
||||||
|
if (user is null)
|
||||||
|
return BadRequest("Email neexistuje.");
|
||||||
|
|
||||||
|
// Zneplatni staré tokeny
|
||||||
|
var old = _db.PasswordResetTokens.Where(t => t.Email == req.Email && !t.Used);
|
||||||
|
_db.PasswordResetTokens.RemoveRange(old);
|
||||||
|
|
||||||
|
// Vygeneruj nový 8-znakový kód
|
||||||
|
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
var rng = new Random();
|
||||||
|
var code = new string(Enumerable.Range(0, 8).Select(_ => chars[rng.Next(chars.Length)]).ToArray());
|
||||||
|
|
||||||
|
_db.PasswordResetTokens.Add(new PasswordResetToken
|
||||||
|
{
|
||||||
|
Email = req.Email,
|
||||||
|
Code = code,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddMinutes(30),
|
||||||
|
Used = false
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _emailService.SendAsync(req.Email, user.Username,
|
||||||
|
"🔑 Obnova hesla — Calendar Reminder",
|
||||||
|
$"""
|
||||||
|
<div style="font-family:Segoe UI,sans-serif;max-width:480px;margin:auto;background:#161b22;color:#e1e4e8;border-radius:10px;overflow:hidden">
|
||||||
|
<div style="background:#1f6feb;padding:1.2rem 1.5rem">
|
||||||
|
<h2 style="margin:0;color:#fff;font-size:1.1rem">🔑 Obnova hesla</h2>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1.5rem">
|
||||||
|
<p style="margin:0 0 1rem">Tvoj kód na obnovu hesla:</p>
|
||||||
|
<div style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:1rem;text-align:center;font-size:2rem;font-weight:700;letter-spacing:0.4rem;color:#58a6ff">{code}</div>
|
||||||
|
<p style="margin:1rem 0 0;color:#8b949e;font-size:0.85rem">Kód je platný 30 minút. Ak si o reset nepožiadal, ignoruj tento email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
""");
|
||||||
|
|
||||||
|
return Ok("Kód bol odoslaný na email.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST api/auth/reset-password
|
||||||
|
[HttpPost("reset-password")]
|
||||||
|
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest req)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == req.Email);
|
||||||
|
if (user is null)
|
||||||
|
return BadRequest("Email neexistuje.");
|
||||||
|
|
||||||
|
// Ak je kód zadaný — over ho
|
||||||
|
if (!string.IsNullOrEmpty(req.Code))
|
||||||
|
{
|
||||||
|
var resetToken = await _db.PasswordResetTokens
|
||||||
|
.Where(t => t.Email == req.Email && !t.Used && t.ExpiresAt > DateTime.UtcNow)
|
||||||
|
.OrderByDescending(t => t.ExpiresAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (resetToken is null || resetToken.Code != req.Code.ToUpper())
|
||||||
|
return BadRequest("Nesprávny alebo expirovaný kód.");
|
||||||
|
|
||||||
|
resetToken.Used = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
|
||||||
|
user.MustChangePassword = false;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok("Heslo bolo úspešne zmenené.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record SendResetCodeRequest(string Email);
|
||||||
|
public record ResetPasswordRequest(string Email, string NewPassword, string? Code);
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ public class AppDbContext : DbContext
|
|||||||
|
|
||||||
public DbSet<Reminder> Reminders => Set<Reminder>();
|
public DbSet<Reminder> Reminders => Set<Reminder>();
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
|
public DbSet<Family> Families => Set<Family>();
|
||||||
public DbSet<ReminderShare> ReminderShares => Set<ReminderShare>();
|
public DbSet<ReminderShare> ReminderShares => Set<ReminderShare>();
|
||||||
|
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -31,6 +33,17 @@ public class AppDbContext : DbContext
|
|||||||
entity.HasIndex(e => e.Username).IsUnique();
|
entity.HasIndex(e => e.Username).IsUnique();
|
||||||
entity.Property(e => e.Email).IsRequired().HasMaxLength(200);
|
entity.Property(e => e.Email).IsRequired().HasMaxLength(200);
|
||||||
entity.Property(e => e.Username).IsRequired().HasMaxLength(100);
|
entity.Property(e => e.Username).IsRequired().HasMaxLength(100);
|
||||||
|
entity.HasOne(e => e.Family)
|
||||||
|
.WithMany(f => f.Users)
|
||||||
|
.HasForeignKey(e => e.FamilyId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Family>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.Name).IsUnique();
|
||||||
|
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ReminderShare>(entity =>
|
modelBuilder.Entity<ReminderShare>(entity =>
|
||||||
|
|||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CalendarReminder.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CalendarReminder.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260610183536_AddPasswordResetTokens")]
|
||||||
|
partial class AddPasswordResetTokens
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.0")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.PasswordResetToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PasswordResetTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("NotificationSent")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReminderDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Reminders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("ReminderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("SharedWithUserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SharedWithUserId");
|
||||||
|
|
||||||
|
b.HasIndex("ReminderId", "SharedWithUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ReminderShares");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.User", "User")
|
||||||
|
.WithMany("Reminders")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.Reminder", "Reminder")
|
||||||
|
.WithMany("Shares")
|
||||||
|
.HasForeignKey("ReminderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.User", "SharedWithUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SharedWithUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Reminder");
|
||||||
|
|
||||||
|
b.Navigation("SharedWithUser");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Shares");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Reminders");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CalendarReminder.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPasswordResetTokens : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PasswordResetTokens",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
Email = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Code = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Used = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PasswordResetTokens", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PasswordResetTokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CalendarReminder.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CalendarReminder.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260611172823_AddFamilyAndAdminFields")]
|
||||||
|
partial class AddFamilyAndAdminFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.0")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Family", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Families");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.PasswordResetToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PasswordResetTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("NotificationSent")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReminderDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Reminders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("ReminderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("SharedWithUserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SharedWithUserId");
|
||||||
|
|
||||||
|
b.HasIndex("ReminderId", "SharedWithUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ReminderShares");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("FamilyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("MustChangePassword")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.User", "User")
|
||||||
|
.WithMany("Reminders")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.Reminder", "Reminder")
|
||||||
|
.WithMany("Shares")
|
||||||
|
.HasForeignKey("ReminderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.User", "SharedWithUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SharedWithUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Reminder");
|
||||||
|
|
||||||
|
b.Navigation("SharedWithUser");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.Family", "Family")
|
||||||
|
.WithMany("Users")
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Family", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Shares");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Reminders");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CalendarReminder.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFamilyAndAdminFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "FamilyId",
|
||||||
|
table: "Users",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAdmin",
|
||||||
|
table: "Users",
|
||||||
|
type: "tinyint(1)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "MustChangePassword",
|
||||||
|
table: "Users",
|
||||||
|
type: "tinyint(1)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Families",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Families", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Users_FamilyId",
|
||||||
|
table: "Users",
|
||||||
|
column: "FamilyId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Families_Name",
|
||||||
|
table: "Families",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Users_Families_FamilyId",
|
||||||
|
table: "Users",
|
||||||
|
column: "FamilyId",
|
||||||
|
principalTable: "Families",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Users_Families_FamilyId",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Families");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Users_FamilyId",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FamilyId",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAdmin",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MustChangePassword",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,54 @@ namespace CalendarReminder.Api.Migrations
|
|||||||
|
|
||||||
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Family", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Families");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.PasswordResetToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PasswordResetTokens");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -101,6 +149,15 @@ namespace CalendarReminder.Api.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("varchar(200)");
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("FamilyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("MustChangePassword")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
b.Property<string>("PasswordHash")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
@@ -115,6 +172,8 @@ namespace CalendarReminder.Api.Migrations
|
|||||||
b.HasIndex("Email")
|
b.HasIndex("Email")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
b.HasIndex("Username")
|
b.HasIndex("Username")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -151,6 +210,21 @@ namespace CalendarReminder.Api.Migrations
|
|||||||
b.Navigation("SharedWithUser");
|
b.Navigation("SharedWithUser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("CalendarReminder.Domain.Entities.Family", "Family")
|
||||||
|
.WithMany("Users")
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Family", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Users");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Shares");
|
b.Navigation("Shares");
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ namespace CalendarReminder.Api.Models;
|
|||||||
|
|
||||||
public record RegisterRequest(string Username, string Email, string Password);
|
public record RegisterRequest(string Username, string Email, string Password);
|
||||||
public record LoginRequest(string Email, string Password);
|
public record LoginRequest(string Email, string Password);
|
||||||
public record AuthResponse(string Token, string Username, string Email);
|
public record AuthResponse(string Token, string Username, string Email, bool IsAdmin, bool MustChangePassword);
|
||||||
|
|||||||
@@ -41,11 +41,23 @@ builder.Services.AddHostedService<ReminderNotificationService>();
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Auto-migrate pri štarte
|
// Auto-migrate + seed admin
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
|
|
||||||
|
if (!db.Users.Any())
|
||||||
|
{
|
||||||
|
db.Users.Add(new CalendarReminder.Domain.Entities.User
|
||||||
|
{
|
||||||
|
Username = "admin",
|
||||||
|
Email = "nas@culak.sk",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Admin@2025"),
|
||||||
|
IsAdmin = true
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ public class TokenService
|
|||||||
{
|
{
|
||||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
new Claim(ClaimTypes.Name, user.Username),
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
new Claim(ClaimTypes.Email, user.Email)
|
new Claim(ClaimTypes.Email, user.Email),
|
||||||
|
new Claim("isAdmin", user.IsAdmin.ToString().ToLower()),
|
||||||
|
new Claim("mustChangePassword", user.MustChangePassword.ToString().ToLower())
|
||||||
};
|
};
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"ConnectionStrings": {
|
||||||
"LogLevel": {
|
"Default": "Server=192.168.1.68;Port=3306;Database=calendarreminder;User=mip;Password=mip@2013;"
|
||||||
"Default": "Information",
|
},
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Jwt": {
|
||||||
}
|
"Secret": "Hhb$bHfHsi@*lqxd1noC&DPK!$SL8fy9x"
|
||||||
|
},
|
||||||
|
"Email": {
|
||||||
|
"Username": "nas@culak.sk",
|
||||||
|
"Password": "qyoq taqy vwpb tyiq",
|
||||||
|
"From": "nas@culak.sk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,19 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Server=192.168.1.68;Port=3306;Database=calendarreminder;User=mip;Password=mip@2013;"
|
"Default": ""
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Secret": "Hhb$bHfHsi@*lqxd1noC&DPK!$SL8fy9x",
|
"Secret": "",
|
||||||
"Issuer": "CalendarReminder",
|
"Issuer": "CalendarReminder",
|
||||||
"Audience": "CalendarReminderUsers"
|
"Audience": "CalendarReminderUsers"
|
||||||
},
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"Host": "smtp.gmail.com",
|
"Host": "smtp.gmail.com",
|
||||||
"Port": 465,
|
"Port": 587,
|
||||||
"Username": "nas@culak.sk",
|
"Username": "",
|
||||||
"Password": "ggTdYzhRfFlK3RoiojOr",
|
"Password": "",
|
||||||
"From": "nas@culak.sk",
|
"From": "",
|
||||||
"UseSsl": true
|
"UseSsl": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,18 @@
|
|||||||
.auth-logo .logo-icon { font-size: 1.8rem; }
|
.auth-logo .logo-icon { font-size: 1.8rem; }
|
||||||
.auth-logo h1 { font-size: 1.45rem; color: #58a6ff; font-weight: 700; letter-spacing: -0.3px; }
|
.auth-logo h1 { font-size: 1.45rem; color: #58a6ff; font-weight: 700; letter-spacing: -0.3px; }
|
||||||
.auth-card > p { color: #8b949e; font-size: 0.88rem; margin-bottom: 1.75rem; padding-left: 2.4rem; }
|
.auth-card > p { color: #8b949e; font-size: 0.88rem; margin-bottom: 1.75rem; padding-left: 2.4rem; }
|
||||||
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
|
.forgot-link { font-size: 0.8rem; color: #58a6ff; cursor: pointer; text-align: right; margin-top: -0.5rem; margin-bottom: 1rem; display: block; background: none; border: none; padding: 0; }
|
||||||
.tab-btn { flex: 1; padding: 0.5rem; border: 1px solid #30363d; border-radius: 7px; background: transparent; color: #8b949e; cursor: pointer; font-size: 0.9rem; transition: all 0.15s; }
|
.forgot-link:hover { text-decoration: underline; }
|
||||||
.tab-btn.active { background: #1f6feb; border-color: #1f6feb; color: #fff; font-weight: 600; }
|
.back-link { font-size: 0.8rem; color: #8b949e; cursor: pointer; background: none; border: none; padding: 0; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.back-link:hover { color: #c9d1d9; }
|
||||||
|
.reset-title { font-size: 0.95rem; font-weight: 600; color: #c9d1d9; margin-bottom: 0.25rem; }
|
||||||
|
.reset-subtitle { font-size: 0.82rem; color: #8b949e; margin-bottom: 1.25rem; }
|
||||||
|
.send-code-row { display: flex; gap: 0.5rem; margin-bottom: 0.9rem; }
|
||||||
|
.send-code-row input { flex: 1; }
|
||||||
|
.btn-send-code { padding: 0.6rem 0.85rem; background: #1f6feb22; border: 1px solid #1f6feb55; border-radius: 7px; color: #58a6ff; cursor: pointer; font-size: 0.82rem; white-space: nowrap; transition: all 0.15s; }
|
||||||
|
.btn-send-code:hover { background: #1f6feb44; }
|
||||||
|
.btn-send-code:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.code-section { display: none; }
|
||||||
.fg { margin-bottom: 0.9rem; }
|
.fg { margin-bottom: 0.9rem; }
|
||||||
.fg label { display: block; font-size: 0.8rem; color: #8b949e; margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.4px; }
|
.fg label { display: block; font-size: 0.8rem; color: #8b949e; margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||||
.fg input { width: 100%; padding: 0.6rem 0.85rem; background: #0d1117; border: 1px solid #30363d; border-radius: 7px; color: #e1e4e8; font-size: 0.95rem; outline: none; transition: border-color 0.15s; }
|
.fg input { width: 100%; padding: 0.6rem 0.85rem; background: #0d1117; border: 1px solid #30363d; border-radius: 7px; color: #e1e4e8; font-size: 0.95rem; outline: none; transition: border-color 0.15s; }
|
||||||
@@ -93,6 +102,114 @@
|
|||||||
}
|
}
|
||||||
.btn-logout:hover { border-color: #f85149; color: #f85149; }
|
.btn-logout:hover { border-color: #f85149; color: #f85149; }
|
||||||
|
|
||||||
|
/* ── AVATAR DROPDOWN ── */
|
||||||
|
.avatar-wrap { position: relative; }
|
||||||
|
.topbar-avatar { cursor: pointer; transition: box-shadow 0.15s; }
|
||||||
|
.topbar-avatar:hover { box-shadow: 0 0 0 2px #388bfd; }
|
||||||
|
.avatar-menu {
|
||||||
|
display: none; position: absolute; top: calc(100% + 8px); right: 0;
|
||||||
|
background: #1c1f2e; border: 1px solid #30363d; border-radius: 9px;
|
||||||
|
min-width: 180px; z-index: 500;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatar-menu.open { display: block; }
|
||||||
|
.avatar-menu-item {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.62rem 1rem; font-size: 0.85rem; color: #c9d1d9;
|
||||||
|
cursor: pointer; transition: background 0.12s; border: none;
|
||||||
|
background: none; width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.avatar-menu-item:hover { background: #30363d; }
|
||||||
|
.avatar-menu-item .mi-icon { font-size: 1rem; }
|
||||||
|
.avatar-menu-sep { border: none; border-top: 1px solid #30363d; margin: 3px 0; }
|
||||||
|
|
||||||
|
/* ── ADMIN PANEL ── */
|
||||||
|
.admin-overlay {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.7); z-index: 600;
|
||||||
|
align-items: flex-start; justify-content: center;
|
||||||
|
overflow-y: auto; padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.admin-overlay.open { display: flex; }
|
||||||
|
.admin-panel {
|
||||||
|
background: #161b22; border: 1px solid #30363d; border-radius: 14px;
|
||||||
|
width: 100%; max-width: 820px;
|
||||||
|
box-shadow: 0 12px 48px rgba(0,0,0,0.7);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.admin-head {
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem; border-bottom: 1px solid #30363d; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.admin-head h2 { font-size: 1rem; color: #c9d1d9; margin: 0; flex: 1; }
|
||||||
|
.btn-admin-close {
|
||||||
|
background: none; border: none; color: #6e7681; cursor: pointer;
|
||||||
|
font-size: 1.2rem; line-height: 1; padding: 2px;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-admin-close:hover { color: #f85149; }
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex; gap: 0; border-bottom: 1px solid #30363d; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.admin-tab {
|
||||||
|
padding: 0.62rem 1.25rem; font-size: 0.85rem; color: #8b949e;
|
||||||
|
cursor: pointer; background: none; border: none;
|
||||||
|
border-bottom: 2px solid transparent; transition: all 0.12s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.admin-tab:hover { color: #c9d1d9; }
|
||||||
|
.admin-tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
|
||||||
|
.admin-body { padding: 1.25rem 1.5rem; flex: 1; }
|
||||||
|
.admin-section { display: none; }
|
||||||
|
.admin-section.active { display: block; }
|
||||||
|
.admin-toolbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.admin-toolbar h3 { font-size: 0.9rem; color: #8b949e; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin: 0; }
|
||||||
|
.btn-admin-add {
|
||||||
|
padding: 0.38rem 0.9rem; background: #238636; border: none; border-radius: 6px;
|
||||||
|
color: #fff; font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-admin-add:hover { background: #2ea043; }
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
.admin-table th {
|
||||||
|
text-align: left; color: #6e7681; font-size: 0.74rem; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px; padding: 0.4rem 0.75rem; border-bottom: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
.admin-table td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #21262d; color: #c9d1d9; vertical-align: middle; }
|
||||||
|
.admin-table tr:last-child td { border-bottom: none; }
|
||||||
|
.admin-table tr:hover td { background: #1c1f2e; }
|
||||||
|
.td-actions { display: flex; gap: 0.4rem; }
|
||||||
|
.btn-tbl { padding: 0.22rem 0.6rem; border-radius: 5px; font-size: 0.76rem; cursor: pointer; border: 1px solid; transition: all 0.12s; }
|
||||||
|
.btn-tbl-edit { border-color: #1f6feb55; color: #58a6ff; background: #1f6feb10; }
|
||||||
|
.btn-tbl-edit:hover { background: #1f6feb33; }
|
||||||
|
.btn-tbl-del { border-color: #f8514955; color: #f85149; background: #f8514910; }
|
||||||
|
.btn-tbl-del:hover { background: #f8514930; }
|
||||||
|
.badge-admin { padding: 1px 7px; border-radius: 8px; font-size: 0.72rem; font-weight: 700; background: #1f6feb22; color: #388bfd; }
|
||||||
|
.badge-mustchange { padding: 1px 7px; border-radius: 8px; font-size: 0.72rem; font-weight: 700; background: #e3b34122; color: #e3b341; }
|
||||||
|
|
||||||
|
/* Mini form in admin */
|
||||||
|
.admin-form { background: #0d1117; border: 1px solid #30363d; border-radius: 9px; padding: 1rem; margin-bottom: 1.25rem; }
|
||||||
|
.admin-form h4 { font-size: 0.8rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 0.75rem; }
|
||||||
|
.admin-form .row { display: flex; gap: 0.6rem; flex-wrap: wrap; }
|
||||||
|
.admin-form .row > * { flex: 1 1 140px; }
|
||||||
|
.admin-form input, .admin-form select {
|
||||||
|
width: 100%; padding: 0.48rem 0.65rem;
|
||||||
|
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||||
|
color: #e1e4e8; font-size: 0.85rem; outline: none; font-family: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.admin-form input:focus, .admin-form select:focus { border-color: #58a6ff; }
|
||||||
|
.admin-form select option { background: #161b22; }
|
||||||
|
.admin-form label { display: block; font-size: 0.75rem; color: #8b949e; margin-bottom: 0.28rem; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.admin-form-actions { display: flex; gap: 0.6rem; margin-top: 0.75rem; justify-content: flex-end; }
|
||||||
|
.btn-admin-save { padding: 0.42rem 1rem; background: #1f6feb; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-weight: 600; font-size: 0.84rem; }
|
||||||
|
.btn-admin-cancel { padding: 0.42rem 0.9rem; background: transparent; border: 1px solid #30363d; border-radius: 6px; color: #8b949e; cursor: pointer; font-size: 0.84rem; }
|
||||||
|
.admin-err { color: #f85149; font-size: 0.8rem; min-height: 1.1rem; margin-top: 0.35rem; }
|
||||||
|
|
||||||
/* ── APP BODY ── */
|
/* ── APP BODY ── */
|
||||||
.app-body {
|
.app-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -349,23 +466,38 @@
|
|||||||
<h1>Calendar Reminder</h1>
|
<h1>Calendar Reminder</h1>
|
||||||
</div>
|
</div>
|
||||||
<p>Správa udalostí s emailovými notifikáciami</p>
|
<p>Správa udalostí s emailovými notifikáciami</p>
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab-btn active" onclick="showTab('login')">Prihlásiť sa</button>
|
<!-- LOGIN -->
|
||||||
<button class="tab-btn" onclick="showTab('register')">Registrácia</button>
|
|
||||||
</div>
|
|
||||||
<div id="login-form">
|
<div id="login-form">
|
||||||
<div class="fg"><label>Email</label><input type="email" id="l-email" placeholder="vas@email.com" /></div>
|
<div class="fg"><label>Email alebo meno</label><input type="text" id="l-email" placeholder="admin" /></div>
|
||||||
<div class="fg"><label>Heslo</label><input type="password" id="l-pass" placeholder="••••••••" onkeydown="if(event.key==='Enter')doLogin()" /></div>
|
<div class="fg"><label>Heslo</label><input type="password" id="l-pass" placeholder="••••••••" onkeydown="if(event.key==='Enter')doLogin()" /></div>
|
||||||
|
<button class="forgot-link" onclick="showReset()">Zabudol som heslo</button>
|
||||||
<button class="btn-primary" onclick="doLogin()">Prihlásiť sa</button>
|
<button class="btn-primary" onclick="doLogin()">Prihlásiť sa</button>
|
||||||
<div class="err" id="l-err"></div>
|
<div class="err" id="l-err"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="register-form" style="display:none">
|
|
||||||
<div class="fg"><label>Meno</label><input type="text" id="r-name" placeholder="Ján Novák" /></div>
|
<!-- RESET HESLA -->
|
||||||
<div class="fg"><label>Email</label><input type="email" id="r-email" placeholder="vas@email.com" /></div>
|
<div id="reset-form" style="display:none">
|
||||||
<div class="fg"><label>Heslo</label><input type="password" id="r-pass" placeholder="min. 6 znakov" onkeydown="if(event.key==='Enter')doRegister()" /></div>
|
<button class="back-link" onclick="showLogin()">← Späť na prihlásenie</button>
|
||||||
<button class="btn-primary" onclick="doRegister()">Vytvoriť účet</button>
|
<div class="reset-title">Obnova hesla</div>
|
||||||
|
<div class="reset-subtitle" id="reset-subtitle">Zadaj email a nové heslo.</div>
|
||||||
|
|
||||||
|
<div class="send-code-row">
|
||||||
|
<input type="email" id="r-email" placeholder="Email *" />
|
||||||
|
<button class="btn-send-code" id="btn-send-code" onclick="sendResetCode()">Zaslať kód</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kód — skrytý, zobrazí sa po aktivácii mailovej služby -->
|
||||||
|
<div class="code-section" id="code-section">
|
||||||
|
<div class="fg"><label>Kód z emailu (8 znakov)</label><input type="text" id="r-code" placeholder="XXXXXXXX" maxlength="8" style="text-transform:uppercase;letter-spacing:0.3rem;font-weight:700" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fg"><label>Nové heslo</label><input type="password" id="r-newpass" placeholder="min. 6 znakov" /></div>
|
||||||
|
<div class="fg"><label>Potvrď heslo</label><input type="password" id="r-newpass2" placeholder="zopakuj heslo" onkeydown="if(event.key==='Enter')doResetPassword()" /></div>
|
||||||
|
<button class="btn-primary" onclick="doResetPassword()">Zmeniť heslo</button>
|
||||||
<div class="err" id="r-err"></div>
|
<div class="err" id="r-err"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -380,9 +512,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="topbar-spacer"></div>
|
<div class="topbar-spacer"></div>
|
||||||
<div class="topbar-user">
|
<div class="topbar-user">
|
||||||
<div class="topbar-avatar" id="hdr-avatar">?</div>
|
|
||||||
<span class="topbar-username" id="hdr-user"></span>
|
<span class="topbar-username" id="hdr-user"></span>
|
||||||
<button class="btn-logout" onclick="doLogout()">Odhlásiť sa</button>
|
<div class="avatar-wrap" id="avatar-wrap">
|
||||||
|
<div class="topbar-avatar" id="hdr-avatar" onclick="toggleAvatarMenu()">?</div>
|
||||||
|
<div class="avatar-menu" id="avatar-menu">
|
||||||
|
<button class="avatar-menu-item" id="mi-users" onclick="openAdmin('users')" style="display:none"><span class="mi-icon">👥</span> Správca užívateľov</button>
|
||||||
|
<button class="avatar-menu-item" id="mi-families" onclick="openAdmin('families')" style="display:none"><span class="mi-icon">🏠</span> Správca rodín</button>
|
||||||
|
<button class="avatar-menu-item" id="mi-settings" onclick="openAdmin('settings')" style="display:none"><span class="mi-icon">⚙️</span> Nastavenia</button>
|
||||||
|
<hr class="avatar-menu-sep" id="mi-sep" style="display:none" />
|
||||||
|
<button class="avatar-menu-item" onclick="doLogout()"><span class="mi-icon">🚪</span> Odhlásiť sa</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -478,6 +618,103 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════ ADMIN PANEL ══════════ -->
|
||||||
|
<div class="admin-overlay" id="admin-overlay" onclick="onAdminOverlayClick(event)">
|
||||||
|
<div class="admin-panel">
|
||||||
|
<div class="admin-head">
|
||||||
|
<h2>⚙️ Administrácia</h2>
|
||||||
|
<button class="btn-admin-close" onclick="closeAdmin()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tabs">
|
||||||
|
<button class="admin-tab" data-tab="users" onclick="switchAdminTab('users')">👥 Správca užívateľov</button>
|
||||||
|
<button class="admin-tab" data-tab="families" onclick="switchAdminTab('families')">🏠 Správca rodín</button>
|
||||||
|
<button class="admin-tab" data-tab="settings" onclick="switchAdminTab('settings')">⚙️ Nastavenia</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-body">
|
||||||
|
|
||||||
|
<!-- USERS TAB -->
|
||||||
|
<div class="admin-section" id="tab-users">
|
||||||
|
<div id="user-form-wrap" style="display:none">
|
||||||
|
<div class="admin-form">
|
||||||
|
<h4 id="user-form-title">Nový používateľ</h4>
|
||||||
|
<input type="hidden" id="uf-id" value="" />
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Meno</label><input id="uf-username" placeholder="johndoe" /></div>
|
||||||
|
<div><label>Email</label><input id="uf-email" type="email" placeholder="john@example.com" /></div>
|
||||||
|
<div><label>Rodina</label>
|
||||||
|
<select id="uf-family"><option value="">— bez rodiny —</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-err" id="uf-err"></div>
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button class="btn-admin-cancel" onclick="hideUserForm()">Zrušiť</button>
|
||||||
|
<button class="btn-admin-save" onclick="saveUser()">Uložiť</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<h3>Používatelia</h3>
|
||||||
|
<button class="btn-admin-add" onclick="showUserForm()">+ Pridať</button>
|
||||||
|
</div>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Meno</th><th>Email</th><th>Rodina</th><th>Roly</th><th></th></tr></thead>
|
||||||
|
<tbody id="admin-users-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAMILIES TAB -->
|
||||||
|
<div class="admin-section" id="tab-families">
|
||||||
|
<div id="family-form-wrap" style="display:none">
|
||||||
|
<div class="admin-form">
|
||||||
|
<h4 id="fam-form-title">Nová rodina</h4>
|
||||||
|
<input type="hidden" id="ff-id" value="" />
|
||||||
|
<div class="row" style="max-width:340px">
|
||||||
|
<div><label>Názov rodiny</label><input id="ff-name" placeholder="Moja rodina" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-err" id="ff-err"></div>
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button class="btn-admin-cancel" onclick="hideFamilyForm()">Zrušiť</button>
|
||||||
|
<button class="btn-admin-save" onclick="saveFamily()">Uložiť</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<h3>Rodiny</h3>
|
||||||
|
<button class="btn-admin-add" onclick="showFamilyForm()">+ Pridať</button>
|
||||||
|
</div>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Názov</th><th>Počet členov</th><th></th></tr></thead>
|
||||||
|
<tbody id="admin-families-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SETTINGS TAB -->
|
||||||
|
<div class="admin-section" id="tab-settings">
|
||||||
|
<p style="color:#8b949e;font-size:0.88rem;margin-top:0.5rem">Nastavenia budú dostupné čoskoro.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MUST-CHANGE-PASSWORD MODAL -->
|
||||||
|
<div class="modal-overlay" id="mcp-modal">
|
||||||
|
<div class="modal" style="max-width:380px">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>🔐 Zmeň si heslo</h2>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:0.84rem;color:#8b949e;margin-bottom:1rem">
|
||||||
|
Tvoj účet bol vytvorený administrátorom.<br>Prosím zmeň si heslo pred pokračovaním.
|
||||||
|
</p>
|
||||||
|
<div class="fg"><label>Nové heslo</label><input type="password" id="mcp-pass" placeholder="min. 6 znakov" /></div>
|
||||||
|
<div class="fg"><label>Potvrď heslo</label><input type="password" id="mcp-pass2" placeholder="zopakuj heslo" onkeydown="if(event.key==='Enter')doMcpChange()" /></div>
|
||||||
|
<div class="err" id="mcp-err"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-save" onclick="doMcpChange()">Zmeniť heslo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = '/api';
|
const API = '/api';
|
||||||
const v = id => document.getElementById(id).value;
|
const v = id => document.getElementById(id).value;
|
||||||
@@ -499,28 +736,90 @@ calMonth = now0.getMonth();
|
|||||||
if (token) showApp();
|
if (token) showApp();
|
||||||
|
|
||||||
// ═══ AUTH ═══
|
// ═══ AUTH ═══
|
||||||
function showTab(t) {
|
function showLogin() {
|
||||||
document.querySelectorAll('.tab-btn').forEach((b,i) => b.classList.toggle('active', i===(t==='login'?0:1)));
|
document.getElementById('login-form').style.display = 'block';
|
||||||
document.getElementById('login-form').style.display = t==='login' ? 'block':'none';
|
document.getElementById('reset-form').style.display = 'none';
|
||||||
document.getElementById('register-form').style.display = t==='register' ? 'block':'none';
|
document.getElementById('l-err').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReset() {
|
||||||
|
document.getElementById('login-form').style.display = 'none';
|
||||||
|
document.getElementById('reset-form').style.display = 'block';
|
||||||
|
document.getElementById('r-err').textContent = '';
|
||||||
|
document.getElementById('reset-subtitle').textContent = 'Zadaj email a nové heslo.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
|
const err = document.getElementById('l-err');
|
||||||
|
err.textContent = '';
|
||||||
const res = await fetch(`${API}/auth/login`, {
|
const res = await fetch(`${API}/auth/login`, {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ email: v('l-email'), password: v('l-pass') })
|
body: JSON.stringify({ email: v('l-email'), password: v('l-pass') })
|
||||||
});
|
});
|
||||||
if (res.ok) { const d = await res.json(); saveAuth(d); showApp(); }
|
if (res.ok) {
|
||||||
else document.getElementById('l-err').textContent = await res.text();
|
const d = await res.json();
|
||||||
|
saveAuth(d);
|
||||||
|
// Reset všetkých auth inputov
|
||||||
|
['l-email','l-pass','r-email','r-newpass','r-newpass2','r-code'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = '';
|
||||||
|
});
|
||||||
|
document.getElementById('l-err').textContent = '';
|
||||||
|
document.getElementById('r-err').textContent = '';
|
||||||
|
showLogin();
|
||||||
|
showApp();
|
||||||
|
} else err.textContent = await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doRegister() {
|
// Zaslanie kódu — aktivuje sa keď bude mailová služba funkčná
|
||||||
const res = await fetch(`${API}/auth/register`, {
|
const RESET_WITH_CODE = true;
|
||||||
|
|
||||||
|
async function sendResetCode() {
|
||||||
|
const email = v('r-email').trim();
|
||||||
|
if (!email) { document.getElementById('r-err').textContent = 'Zadaj email.'; return; }
|
||||||
|
const btn = document.getElementById('btn-send-code');
|
||||||
|
btn.disabled = true;
|
||||||
|
const res = await fetch(`${API}/auth/send-reset-code`, {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ username: v('r-name'), email: v('r-email'), password: v('r-pass') })
|
body: JSON.stringify({ email })
|
||||||
});
|
});
|
||||||
if (res.ok) { const d = await res.json(); saveAuth(d); showApp(); }
|
btn.disabled = false;
|
||||||
else document.getElementById('r-err').textContent = await res.text();
|
if (res.ok) {
|
||||||
|
document.getElementById('code-section').style.display = 'block';
|
||||||
|
document.getElementById('reset-subtitle').textContent = 'Kód bol odoslaný na email. Zadaj ho nižšie.';
|
||||||
|
document.getElementById('r-err').style.color = '#3fb950';
|
||||||
|
document.getElementById('r-err').textContent = '✓ Kód odoslaný';
|
||||||
|
} else {
|
||||||
|
document.getElementById('r-err').style.color = '#f85149';
|
||||||
|
document.getElementById('r-err').textContent = await res.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doResetPassword() {
|
||||||
|
const err = document.getElementById('r-err');
|
||||||
|
err.style.color = '#f85149';
|
||||||
|
err.textContent = '';
|
||||||
|
const email = v('r-email').trim();
|
||||||
|
const pass = v('r-newpass');
|
||||||
|
const pass2 = v('r-newpass2');
|
||||||
|
const code = v('r-code').trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!email) { err.textContent = 'Zadaj email.'; return; }
|
||||||
|
if (pass.length < 6) { err.textContent = 'Heslo musí mať aspoň 6 znakov.'; return; }
|
||||||
|
if (pass !== pass2) { err.textContent = 'Heslá sa nezhodujú.'; return; }
|
||||||
|
if (RESET_WITH_CODE && !code) { err.textContent = 'Zadaj kód z emailu.'; return; }
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/auth/reset-password`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ email, newPassword: pass, code: RESET_WITH_CODE ? code : null })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
err.style.color = '#3fb950';
|
||||||
|
err.textContent = '✓ Heslo bolo zmenené. Môžeš sa prihlásiť.';
|
||||||
|
setTimeout(showLogin, 2000);
|
||||||
|
} else {
|
||||||
|
err.textContent = await res.text();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAuth(d) {
|
function saveAuth(d) {
|
||||||
@@ -535,10 +834,13 @@ function saveAuth(d) {
|
|||||||
token = d.token;
|
token = d.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isAdmin = false;
|
||||||
|
|
||||||
function doLogout() {
|
function doLogout() {
|
||||||
localStorage.clear(); token = null; myId = 0;
|
localStorage.clear(); token = null; myId = 0; isAdmin = false;
|
||||||
document.getElementById('auth-screen').style.display = 'flex';
|
document.getElementById('auth-screen').style.display = 'flex';
|
||||||
document.getElementById('app-screen').style.display = 'none';
|
document.getElementById('app-screen').style.display = 'none';
|
||||||
|
document.getElementById('avatar-menu').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showApp() {
|
function showApp() {
|
||||||
@@ -546,8 +848,23 @@ function showApp() {
|
|||||||
document.getElementById('app-screen').style.display = 'flex';
|
document.getElementById('app-screen').style.display = 'flex';
|
||||||
const u = JSON.parse(localStorage.getItem('user') || '{}');
|
const u = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
const name = u.username || '';
|
const name = u.username || '';
|
||||||
|
isAdmin = !!u.isAdmin;
|
||||||
document.getElementById('hdr-user').textContent = name;
|
document.getElementById('hdr-user').textContent = name;
|
||||||
document.getElementById('hdr-avatar').textContent = name ? name[0].toUpperCase() : '?';
|
document.getElementById('hdr-avatar').textContent = name ? name[0].toUpperCase() : '?';
|
||||||
|
|
||||||
|
// Admin menu items
|
||||||
|
['mi-users','mi-families','mi-settings','mi-sep'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = isAdmin ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// MustChangePassword check
|
||||||
|
if (u.mustChangePassword) {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('mcp-modal').classList.add('open');
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
loadReminders();
|
loadReminders();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,12 +1237,222 @@ function removeUser(id) { selectedUsers = selectedUsers.filter(u=>u.id!==id); re
|
|||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
if (!document.getElementById('user-picker').contains(e.target))
|
if (!document.getElementById('user-picker').contains(e.target))
|
||||||
document.getElementById('picker-dropdown').classList.remove('open');
|
document.getElementById('picker-dropdown').classList.remove('open');
|
||||||
|
if (!document.getElementById('avatar-wrap').contains(e.target))
|
||||||
|
document.getElementById('avatar-menu').classList.remove('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
requestAnimationFrame(() => trimCalendarCells());
|
requestAnimationFrame(() => trimCalendarCells());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══ AVATAR MENU ═══
|
||||||
|
function toggleAvatarMenu() {
|
||||||
|
document.getElementById('avatar-menu').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ MUST-CHANGE-PASSWORD ═══
|
||||||
|
async function doMcpChange() {
|
||||||
|
const err = document.getElementById('mcp-err');
|
||||||
|
err.textContent = '';
|
||||||
|
const pass = document.getElementById('mcp-pass').value;
|
||||||
|
const pass2 = document.getElementById('mcp-pass2').value;
|
||||||
|
const u = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
if (pass.length < 6) { err.textContent = 'Heslo musí mať aspoň 6 znakov.'; return; }
|
||||||
|
if (pass !== pass2) { err.textContent = 'Heslá sa nezhodujú.'; return; }
|
||||||
|
|
||||||
|
// 1. Zmeniť heslo — server zároveň nastaví MustChangePassword = false
|
||||||
|
const res = await fetch(`${API}/auth/reset-password`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ email: u.email, newPassword: pass, code: null })
|
||||||
|
});
|
||||||
|
if (!res.ok) { err.textContent = await res.text(); return; }
|
||||||
|
|
||||||
|
// 2. Znovu prihlásiť — dostaneme nový token s mustChangePassword: false
|
||||||
|
const loginRes = await fetch(`${API}/auth/login`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ email: u.email, password: pass })
|
||||||
|
});
|
||||||
|
if (loginRes.ok) {
|
||||||
|
const d = await loginRes.json();
|
||||||
|
saveAuth(d);
|
||||||
|
// Aktualizuj meno/avatar v topbare (zostáva rovnaké, ale token je nový)
|
||||||
|
document.getElementById('hdr-user').textContent = d.username;
|
||||||
|
document.getElementById('hdr-avatar').textContent = d.username ? d.username[0].toUpperCase() : '?';
|
||||||
|
}
|
||||||
|
document.getElementById('mcp-modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ ADMIN PANEL ═══
|
||||||
|
let adminFamilies = [];
|
||||||
|
|
||||||
|
function openAdmin(tab) {
|
||||||
|
document.getElementById('avatar-menu').classList.remove('open');
|
||||||
|
document.getElementById('admin-overlay').classList.add('open');
|
||||||
|
switchAdminTab(tab || 'users');
|
||||||
|
}
|
||||||
|
function closeAdmin() {
|
||||||
|
document.getElementById('admin-overlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
function onAdminOverlayClick(e) {
|
||||||
|
if (e.target === document.getElementById('admin-overlay')) closeAdmin();
|
||||||
|
}
|
||||||
|
function switchAdminTab(tab) {
|
||||||
|
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
document.querySelectorAll('.admin-section').forEach(s => s.classList.toggle('active', s.id === `tab-${tab}`));
|
||||||
|
if (tab === 'users') loadAdminData();
|
||||||
|
if (tab === 'families') loadAdminFamilies();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminData() {
|
||||||
|
const [uRes, fRes] = await Promise.all([
|
||||||
|
fetch(`${API}/admin/users`, { headers: auth() }),
|
||||||
|
fetch(`${API}/admin/families`, { headers: auth() })
|
||||||
|
]);
|
||||||
|
const users = uRes.ok ? await uRes.json() : [];
|
||||||
|
adminFamilies = fRes.ok ? await fRes.json() : [];
|
||||||
|
renderAdminUsers(users);
|
||||||
|
populateFamilySelect('uf-family', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminFamilies() {
|
||||||
|
const res = await fetch(`${API}/admin/families`, { headers: auth() });
|
||||||
|
adminFamilies = res.ok ? await res.json() : [];
|
||||||
|
renderAdminFamilies(adminFamilies);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminUsers(users) {
|
||||||
|
const tbody = document.getElementById('admin-users-tbody');
|
||||||
|
if (!users.length) { tbody.innerHTML = '<tr><td colspan="5" style="color:#484f58;text-align:center;padding:1.5rem">Žiadni používatelia</td></tr>'; return; }
|
||||||
|
tbody.innerHTML = users.map(u => `
|
||||||
|
<tr>
|
||||||
|
<td>${u.username}</td>
|
||||||
|
<td style="color:#8b949e">${u.email}</td>
|
||||||
|
<td>${u.familyName || '<span style="color:#484f58">—</span>'}</td>
|
||||||
|
<td>
|
||||||
|
${u.isAdmin ? '<span class="badge-admin">Admin</span>' : ''}
|
||||||
|
${u.mustChangePassword ? '<span class="badge-mustchange">Zmeniť heslo</span>' : ''}
|
||||||
|
</td>
|
||||||
|
<td><div class="td-actions">
|
||||||
|
<button class="btn-tbl btn-tbl-edit" onclick="editUser(${u.id})">✏️ Upraviť</button>
|
||||||
|
${u.isAdmin ? '' : `<button class="btn-tbl btn-tbl-del" onclick="deleteUser(${u.id},'${u.username}')">🗑 Zmazať</button>`}
|
||||||
|
</div></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminFamilies(families) {
|
||||||
|
const tbody = document.getElementById('admin-families-tbody');
|
||||||
|
if (!families.length) { tbody.innerHTML = '<tr><td colspan="3" style="color:#484f58;text-align:center;padding:1.5rem">Žiadne rodiny</td></tr>'; return; }
|
||||||
|
tbody.innerHTML = families.map(f => `
|
||||||
|
<tr>
|
||||||
|
<td>${f.name}</td>
|
||||||
|
<td style="color:#8b949e">${f.memberCount}</td>
|
||||||
|
<td><div class="td-actions">
|
||||||
|
<button class="btn-tbl btn-tbl-edit" onclick="editFamily(${f.id},'${f.name.replace(/'/g,"\\'")}')">✏️ Upraviť</button>
|
||||||
|
<button class="btn-tbl btn-tbl-del" onclick="deleteFamily(${f.id},'${f.name.replace(/'/g,"\\'")}')">🗑 Zmazať</button>
|
||||||
|
</div></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFamilySelect(selectId, selectedId) {
|
||||||
|
const sel = document.getElementById(selectId);
|
||||||
|
sel.innerHTML = '<option value="">— bez rodiny —</option>' +
|
||||||
|
adminFamilies.map(f => `<option value="${f.id}" ${f.id == selectedId ? 'selected' : ''}>${f.name}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER FORM
|
||||||
|
function showUserForm(clearId) {
|
||||||
|
document.getElementById('uf-id').value = '';
|
||||||
|
document.getElementById('uf-username').value = '';
|
||||||
|
document.getElementById('uf-email').value = '';
|
||||||
|
document.getElementById('uf-err').textContent = '';
|
||||||
|
document.getElementById('user-form-title').textContent = 'Nový používateľ';
|
||||||
|
populateFamilySelect('uf-family', null);
|
||||||
|
document.getElementById('user-form-wrap').style.display = '';
|
||||||
|
document.getElementById('uf-username').focus();
|
||||||
|
}
|
||||||
|
function hideUserForm() { document.getElementById('user-form-wrap').style.display = 'none'; }
|
||||||
|
function editUser(id) {
|
||||||
|
// Find from table — reload users first
|
||||||
|
fetch(`${API}/admin/users`, { headers: auth() }).then(r => r.json()).then(users => {
|
||||||
|
const u = users.find(x => x.id === id); if (!u) return;
|
||||||
|
document.getElementById('uf-id').value = u.id;
|
||||||
|
document.getElementById('uf-username').value = u.username;
|
||||||
|
document.getElementById('uf-email').value = u.email;
|
||||||
|
document.getElementById('uf-err').textContent = '';
|
||||||
|
document.getElementById('user-form-title').textContent = 'Upraviť používateľa';
|
||||||
|
populateFamilySelect('uf-family', u.familyId);
|
||||||
|
document.getElementById('user-form-wrap').style.display = '';
|
||||||
|
document.getElementById('uf-username').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function saveUser() {
|
||||||
|
const id = document.getElementById('uf-id').value;
|
||||||
|
const body = {
|
||||||
|
username: document.getElementById('uf-username').value.trim(),
|
||||||
|
email: document.getElementById('uf-email').value.trim(),
|
||||||
|
familyId: document.getElementById('uf-family').value ? parseInt(document.getElementById('uf-family').value) : null
|
||||||
|
};
|
||||||
|
const err = document.getElementById('uf-err');
|
||||||
|
err.textContent = '';
|
||||||
|
if (!body.username || !body.email) { err.textContent = 'Meno a email sú povinné.'; return; }
|
||||||
|
const url = id ? `${API}/admin/users/${id}` : `${API}/admin/users`;
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, { method, headers: { ...auth(), 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
if (res.ok) {
|
||||||
|
hideUserForm();
|
||||||
|
loadAdminData();
|
||||||
|
} else {
|
||||||
|
err.textContent = await res.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteUser(id, name) {
|
||||||
|
if (!confirm(`Zmazať používateľa „${name}"?`)) return;
|
||||||
|
const res = await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: auth() });
|
||||||
|
if (res.ok) loadAdminData();
|
||||||
|
else alert(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAMILY FORM
|
||||||
|
function showFamilyForm() {
|
||||||
|
document.getElementById('ff-id').value = '';
|
||||||
|
document.getElementById('ff-name').value = '';
|
||||||
|
document.getElementById('ff-err').textContent = '';
|
||||||
|
document.getElementById('fam-form-title').textContent = 'Nová rodina';
|
||||||
|
document.getElementById('family-form-wrap').style.display = '';
|
||||||
|
document.getElementById('ff-name').focus();
|
||||||
|
}
|
||||||
|
function hideFamilyForm() { document.getElementById('family-form-wrap').style.display = 'none'; }
|
||||||
|
function editFamily(id, name) {
|
||||||
|
document.getElementById('ff-id').value = id;
|
||||||
|
document.getElementById('ff-name').value = name;
|
||||||
|
document.getElementById('ff-err').textContent = '';
|
||||||
|
document.getElementById('fam-form-title').textContent = 'Upraviť rodinu';
|
||||||
|
document.getElementById('family-form-wrap').style.display = '';
|
||||||
|
document.getElementById('ff-name').focus();
|
||||||
|
}
|
||||||
|
async function saveFamily() {
|
||||||
|
const id = document.getElementById('ff-id').value;
|
||||||
|
const name = document.getElementById('ff-name').value.trim();
|
||||||
|
const err = document.getElementById('ff-err');
|
||||||
|
err.textContent = '';
|
||||||
|
if (!name) { err.textContent = 'Názov je povinný.'; return; }
|
||||||
|
const url = id ? `${API}/admin/families/${id}` : `${API}/admin/families`;
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, { method, headers: { ...auth(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||||
|
if (res.ok) {
|
||||||
|
hideFamilyForm();
|
||||||
|
loadAdminFamilies();
|
||||||
|
} else {
|
||||||
|
err.textContent = await res.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteFamily(id, name) {
|
||||||
|
if (!confirm(`Zmazať rodinu „${name}"?`)) return;
|
||||||
|
const res = await fetch(`${API}/admin/families/${id}`, { method: 'DELETE', headers: auth() });
|
||||||
|
if (res.ok) loadAdminFamilies();
|
||||||
|
else alert(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace CalendarReminder.Domain.Entities;
|
||||||
|
|
||||||
|
public class Family
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public ICollection<User> Users { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace CalendarReminder.Domain.Entities;
|
||||||
|
|
||||||
|
public class PasswordResetToken
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public string Code { get; set; } = ""; // 8 znakov
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public bool Used { get; set; }
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ public class User
|
|||||||
public string Email { get; set; } = "";
|
public string Email { get; set; } = "";
|
||||||
public string PasswordHash { get; set; } = "";
|
public string PasswordHash { get; set; } = "";
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool IsAdmin { get; set; }
|
||||||
|
public bool MustChangePassword { get; set; }
|
||||||
|
|
||||||
|
public int? FamilyId { get; set; }
|
||||||
|
public Family? Family { get; set; }
|
||||||
|
|
||||||
public ICollection<Reminder> Reminders { get; set; } = [];
|
public ICollection<Reminder> Reminders { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- CalendarReminder — vytvorenie admin používateľa
|
||||||
|
-- Heslo: Admin@2025
|
||||||
|
-- Spusti: mysql -h 192.168.1.68 -u mip -p"mip@2013" calendarreminder < create-admin.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO Users (Username, Email, PasswordHash, IsAdmin, MustChangePassword, CreatedAt)
|
||||||
|
VALUES (
|
||||||
|
'admin',
|
||||||
|
'nas@culak.sk',
|
||||||
|
'$2a$11$8yPi0lcuKsJH6/Ncvm2A6O4Nw9gZblkCZ23PxbibW5MVfG06syp5S',
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
UTC_TIMESTAMP()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
Email = VALUES(Email),
|
||||||
|
PasswordHash = VALUES(PasswordHash),
|
||||||
|
IsAdmin = 1;
|
||||||
|
|
||||||
|
-- Over výsledok
|
||||||
|
SELECT Id, Username, Email, IsAdmin, MustChangePassword FROM Users WHERE Username = 'admin';
|
||||||
+32
-8
@@ -1,23 +1,34 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
image: ${DOCKERHUB_USERNAME}/calendarreminder-app:latest
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
|
||||||
- ConnectionStrings__Default=Server=db;Port=3306;Database=calendarreminder;User=appuser;Password=secret;
|
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
- ConnectionStrings__Default=Server=${DB_SERVER};Port=${DB_PORT};Database=${DB_NAME};User=${DB_USER};Password=${DB_PASSWORD};
|
||||||
|
- Jwt__Secret=${JWT_SECRET}
|
||||||
|
- Jwt__Issuer=CalendarReminder
|
||||||
|
- Jwt__Audience=CalendarReminderUsers
|
||||||
|
- Email__Host=${EMAIL_HOST}
|
||||||
|
- Email__Port=${EMAIL_PORT}
|
||||||
|
- Email__Username=${EMAIL_USER}
|
||||||
|
- Email__Password=${EMAIL_PASS}
|
||||||
|
- Email__From=${EMAIL_USER}
|
||||||
|
- Email__UseSsl=${EMAIL_SSL}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mariadb:11
|
image: mariadb:11
|
||||||
environment:
|
environment:
|
||||||
MARIADB_ROOT_PASSWORD: rootpass
|
- MARIADB_ROOT_PASSWORD=${DB_PASSWORD}
|
||||||
MARIADB_DATABASE: calendarreminder
|
- MARIADB_DATABASE=${DB_NAME}
|
||||||
MARIADB_USER: appuser
|
- MARIADB_USER=${DB_USER}
|
||||||
MARIADB_PASSWORD: secret
|
- MARIADB_PASSWORD=${DB_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/tank/calendarreminder/mariadb:/var/lib/mysql
|
- /mnt/tank/calendarreminder/mariadb:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -26,3 +37,16 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower
|
||||||
|
ports:
|
||||||
|
- "8085:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
- WATCHTOWER_LABEL_ENABLE=true
|
||||||
|
- WATCHTOWER_CLEANUP=true
|
||||||
|
- WATCHTOWER_HTTP_API_UPDATE=true
|
||||||
|
- WATCHTOWER_HTTP_API_TOKEN=${WATCHTOWER_TOKEN}
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
Reference in New Issue
Block a user