diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs index a5e1caf..4aaaee8 100644 --- a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -17,7 +17,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - var people = await medicalDocsService.GetPeopleAsync(); + var people = await medicalDocsService.GetPeopleAsync(userId.Value); return Ok(people); } @@ -31,13 +31,68 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); - var person = await medicalDocsService.CreatePersonAsync(request.Name.Trim(), request.DateOfBirth, request.Notes); + var person = await medicalDocsService.CreatePersonAsync(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes); if (person == null) return StatusCode(500, new { error = "Failed to create person" }); return Ok(person); } + // --- People Access --- + + [HttpGet("people/{personId}/access")] + public async Task GetPersonAccess(long personId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + var users = await medicalDocsService.GetPeopleAccessAsync(personId); + return Ok(users); + } + + [HttpPost("people/{personId}/access")] + public async Task GrantPersonAccess(long personId, [FromBody] GrantAccessRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Username)) + return BadRequest(new { error = "Username is required" }); + + var targetUser = await dbExecutor.ExecuteReaderAsync( + "SELECT id FROM app.users WHERE LOWER(username) = LOWER(@username)", + reader => reader.GetInt64(0), + new { username = request.Username.Trim() }); + + if (targetUser == 0) + return NotFound(new { error = "User not found" }); + + var success = await medicalDocsService.GrantAccessAsync(personId, targetUser); + if (!success) + return StatusCode(500, new { error = "Failed to grant access" }); + + return Ok(new { success = true }); + } + + [HttpDelete("people/{personId}/access/{targetUserId}")] + public async Task RevokePersonAccess(long personId, long targetUserId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + var success = await medicalDocsService.RevokeAccessAsync(personId, targetUserId); + if (!success) + return BadRequest(new { error = "Cannot revoke access — at least one user must have access" }); + + return Ok(new { success = true }); + } + // --- Documents --- [HttpGet("documents")] @@ -46,6 +101,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 100) limit = 50; if (offset < 0) offset = 0; @@ -75,6 +131,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); if (limit < 1 || limit > 100) limit = 50; if (offset < 0) offset = 0; @@ -92,6 +149,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var tags = await medicalDocsService.GetPersonTagsAsync(personId); return Ok(tags); @@ -104,6 +162,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); if (file == null || file.Length == 0) return BadRequest(new { error = "No file provided" }); @@ -126,6 +185,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(new { error = "Title is required" }); @@ -144,6 +204,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -157,6 +218,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -176,6 +238,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId); if (!success) return NotFound(new { error = "Document not found" }); @@ -189,6 +252,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var success = await medicalDocsService.DeleteDocumentAsync(id); if (!success) return NotFound(new { error = "Document not found" }); @@ -204,6 +268,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -260,12 +325,13 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var tags = await medicalDocsService.GetDocumentTagsAsync(id); return Ok(tags); } - // --- Doctors --- + // --- Doctors (shared, no per-person access check) --- [HttpGet("doctors")] public async Task GetDoctors() @@ -335,6 +401,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var conditions = await medicalDocsService.GetConditionsAsync(personId); return Ok(conditions); @@ -349,6 +416,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -365,6 +433,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -381,6 +450,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid(); var success = await medicalDocsService.DeleteConditionAsync(id); if (!success) return NotFound(new { error = "Condition not found" }); @@ -399,6 +469,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId); return Ok(prescriptions); @@ -413,6 +484,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.MedicationName)) return BadRequest(new { error = "Medication name is required" }); @@ -429,6 +501,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.MedicationName)) return BadRequest(new { error = "Medication name is required" }); @@ -445,6 +518,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var success = await medicalDocsService.DeletePrescriptionAsync(id); if (!success) return NotFound(new { error = "Prescription not found" }); @@ -460,6 +534,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var pickups = await medicalDocsService.GetPickupsAsync(id); return Ok(pickups); @@ -471,6 +546,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes); if (pickup == null) @@ -485,6 +561,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "pickup", id)) return Forbid(); var success = await medicalDocsService.DeletePickupAsync(id); if (!success) return NotFound(new { error = "Pickup not found" }); @@ -503,6 +580,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var providers = await medicalDocsService.GetProvidersAsync(personId); return Ok(providers); @@ -517,6 +595,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -533,6 +612,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -549,6 +629,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); var success = await medicalDocsService.DeleteProviderAsync(id); if (!success) return NotFound(new { error = "Provider not found" }); @@ -564,6 +645,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); var payments = await medicalDocsService.GetProviderPaymentsAsync(id); return Ok(payments); @@ -575,6 +657,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); if (request.Amount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -592,6 +675,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider-payment", id)) return Forbid(); var success = await medicalDocsService.DeleteProviderPaymentAsync(id); if (!success) return NotFound(new { error = "Payment not found" }); @@ -610,6 +694,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var bills = await medicalDocsService.GetBillsAsync(personId, providerId); return Ok(bills); @@ -624,6 +709,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (request.TotalAmount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -640,6 +726,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (request.TotalAmount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -656,6 +743,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); var success = await medicalDocsService.DeleteBillAsync(id); if (!success) return NotFound(new { error = "Bill not found" }); @@ -669,6 +757,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (request.DocumentId <= 0) return BadRequest(new { error = "Document is required" }); @@ -686,6 +775,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", billId)) return Forbid(); var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId); if (!success) return NotFound(new { error = "Link not found" }); @@ -701,6 +791,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); var charges = await medicalDocsService.GetChargesAsync(id); return Ok(charges); @@ -712,6 +803,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Description)) return BadRequest(new { error = "Description is required" }); @@ -731,6 +823,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill-charge", id)) return Forbid(); var success = await medicalDocsService.DeleteChargeAsync(id); if (!success) return NotFound(new { error = "Charge not found" }); @@ -749,6 +842,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); if (limit < 1 || limit > 200) limit = 100; if (offset < 0) offset = 0; @@ -768,6 +862,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0 || doctorId <= 0) return BadRequest(new { error = "personId and doctorId are required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId); return Ok(data); @@ -782,6 +877,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0 || request.DoctorId <= 0) return BadRequest(new { error = "personId and doctorId are required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId); var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId); @@ -801,12 +897,13 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var summary = await medicalDocsService.GetBillSummaryAsync(personId); return Ok(summary); } - // --- Auth helpers (same pattern as AdminApi) --- + // --- Auth helpers --- private async Task HasMedicalAccess(long userId) { @@ -815,6 +912,18 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc new { UserId = userId }); } + private async Task HasPersonAccess(long userId, long personId) + { + return await medicalDocsService.HasAccessToPersonAsync(userId, personId); + } + + private async Task HasResourceAccess(long userId, string resourceType, long resourceId) + { + var personId = await medicalDocsService.GetPersonIdForResourceAsync(resourceType, resourceId); + if (personId == null) return false; + return await medicalDocsService.HasAccessToPersonAsync(userId, personId.Value); + } + private long? GetUserIdFromAuth() { var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; @@ -851,3 +960,4 @@ public record LinkDocumentRequest(long DocumentId); public record CreateChargeRequest(string Description, decimal Amount); public record ProcessBatchRequest(List DocumentIds); public record VisitPrepSummaryRequest(long PersonId, long DoctorId); +public record GrantAccessRequest(string Username); diff --git a/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql new file mode 100644 index 0000000..187086d --- /dev/null +++ b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE app.password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id); diff --git a/Media.JoshHeaps.Net/Database/026_medical_people_access.sql b/Media.JoshHeaps.Net/Database/026_medical_people_access.sql new file mode 100644 index 0000000..4e9d8b2 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/026_medical_people_access.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS app.medical_people_access ( + id BIGSERIAL PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_mpa_user_id ON app.medical_people_access(user_id); +CREATE INDEX IF NOT EXISTS idx_mpa_person_id ON app.medical_people_access(person_id); diff --git a/Media.JoshHeaps.Net/Models/MedicalPerson.cs b/Media.JoshHeaps.Net/Models/MedicalPerson.cs index 14e9c25..02ac67a 100644 --- a/Media.JoshHeaps.Net/Models/MedicalPerson.cs +++ b/Media.JoshHeaps.Net/Models/MedicalPerson.cs @@ -9,3 +9,9 @@ public class MedicalPerson public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } + +public class PersonAccessUser +{ + public long Id { get; set; } + public string Username { get; set; } = string.Empty; +} diff --git a/Media.JoshHeaps.Net/Pages/Gallery.cshtml b/Media.JoshHeaps.Net/Pages/Gallery.cshtml index 9c8fd76..94cf68b 100644 --- a/Media.JoshHeaps.Net/Pages/Gallery.cshtml +++ b/Media.JoshHeaps.Net/Pages/Gallery.cshtml @@ -21,6 +21,7 @@

Welcome back, @Model.Dashboard?.Username!

+
@if (Model.Dashboard?.EmailVerified == false) { diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml b/Media.JoshHeaps.Net/Pages/Login.cshtml index 79ee689..6e0637e 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml @@ -60,10 +60,13 @@
-
- - +
+
+ + +
+ Forgot password?
diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs index ec68471..cf5a8a0 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs @@ -20,7 +20,7 @@ public class LoginModel(AuthService authService) : PageModel public string? SuccessMessage { get; set; } public string? WarningMessage { get; set; } - public void OnGet([FromQuery] string? registered, [FromQuery] string? verified) + public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset) { // Check if user is already logged in var userId = HttpContext.Session.GetString("UserId"); @@ -41,6 +41,12 @@ public class LoginModel(AuthService authService) : PageModel { SuccessMessage = "Email verified! You can now sign in."; } + + // Show success message if password was just reset + if (reset == "true") + { + SuccessMessage = "Your password has been reset. You can now sign in with your new password."; + } } public async Task OnPostAsync() diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml new file mode 100644 index 0000000..7df47af --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml @@ -0,0 +1,102 @@ +@page +@model Media.JoshHeaps.Net.Pages.LoginHelpModel +@{ + ViewData["Title"] = "Login Help"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@section Scripts { + +} + +
+
+ @if (Model.ShowResetForm) + { +
+

Reset Password

+

Enter your new password below

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + +
+ @Html.AntiForgeryToken() + + +
+ +
+ + +
+
+
+
+
+
+
+ +
+ +
+ + +
+
+
+ + +
+ } + else + { +
+

Forgot Password

+

Enter your email to receive a reset link

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { +
+ @Model.SuccessMessage +
+ } + +
+ @Html.AntiForgeryToken() + +
+ + +
+
+ + +
+ } + + +
+
diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs new file mode 100644 index 0000000..f55031c --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs @@ -0,0 +1,111 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Media.JoshHeaps.Net.Pages; + +public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger logger) : PageModel +{ + [BindProperty] + public string Email { get; set; } = string.Empty; + + [BindProperty] + public string Token { get; set; } = string.Empty; + + [BindProperty] + public string NewPassword { get; set; } = string.Empty; + + [BindProperty] + public string ConfirmPassword { get; set; } = string.Empty; + + public string? ErrorMessage { get; set; } + public string? SuccessMessage { get; set; } + public bool ShowResetForm { get; set; } + + public async Task OnGetAsync([FromQuery] string? token) + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (!string.IsNullOrEmpty(token)) + { + var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token); + if (valid) + { + ShowResetForm = true; + Token = token; + } + else + { + ErrorMessage = error; + } + } + + return Page(); + } + + public async Task OnPostRequestResetAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(Email)) + { + ErrorMessage = "Please enter your email address."; + return Page(); + } + + var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim()); + + if (!success) + { + logger.LogError("Password reset request failed for {Email}: {Error}", Email, error); + } + + // Send email if we got a token back (user exists and is eligible) + if (token != null) + { + await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token); + } + + // Always show the same message regardless of whether the email exists + SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly."; + return Page(); + } + + public async Task OnPostResetPasswordAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8) + { + ErrorMessage = "Password must be at least 8 characters."; + ShowResetForm = true; + return Page(); + } + + if (NewPassword != ConfirmPassword) + { + ErrorMessage = "Passwords do not match."; + ShowResetForm = true; + return Page(); + } + + var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword); + + if (!success) + { + ErrorMessage = error; + return Page(); + } + + return Redirect("/Login?reset=true"); + } +} diff --git a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml index abd2830..be1579f 100644 --- a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml +++ b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml @@ -285,6 +285,23 @@
+ + + @section Scripts { diff --git a/Media.JoshHeaps.Net/Services/AuthService.cs b/Media.JoshHeaps.Net/Services/AuthService.cs index 5106df3..340e11b 100644 --- a/Media.JoshHeaps.Net/Services/AuthService.cs +++ b/Media.JoshHeaps.Net/Services/AuthService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Media.JoshHeaps.Net; using Media.JoshHeaps.Net.Models; @@ -302,4 +304,173 @@ public class AuthService(DbExecutor db) new { userId, lastLogin = DateTime.UtcNow } ); } + + public static string GenerateSecureToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + public static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email) + { + try + { + var userRow = await db.ExecuteReaderAsync( + "SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email", + reader => new + { + UserId = reader.GetInt64(0), + Username = reader.GetString(1), + IsActive = reader.GetBoolean(2), + LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3) + }, + new { email } + ); + + if (userRow == null) + { + // Artificial delay to prevent timing-based email enumeration + await Task.Delay(Random.Shared.Next(100, 300)); + return (true, null, null, null); + } + + // Silently succeed for inactive/locked accounts (don't reveal state) + if (!userRow.IsActive || + (userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow)) + { + return (true, null, null, null); + } + + // Rate limit: max 3 requests per hour + var recentCount = await db.ExecuteAsync( + @"SELECT COUNT(*) FROM app.password_reset_tokens + WHERE user_id = @userId AND created_at > @cutoff", + new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) } + ); + + if (recentCount >= 3) + { + return (true, null, null, null); + } + + // Invalidate all existing unused tokens for this user + await db.ExecuteNonQueryAsync( + @"UPDATE app.password_reset_tokens + SET used_at = @now + WHERE user_id = @userId AND used_at IS NULL", + new { userId = userRow.UserId, now = DateTimeOffset.UtcNow } + ); + + // Generate and store new token + var token = GenerateSecureToken(); + var tokenHash = HashToken(token); + var expiresAt = DateTimeOffset.UtcNow.AddHours(1); + + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at) + VALUES (@userId, @tokenHash, @expiresAt)", + new { userId = userRow.UserId, tokenHash, expiresAt } + ); + + return (true, null, token, userRow.Username); + } + catch (Exception ex) + { + return (false, $"Password reset request failed: {ex.Message}", null, null); + } + } + + public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + ExpiresAt = reader.GetFieldValue(0), + UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue(1) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Token validation failed: {ex.Message}"); + } + } + + public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + Id = reader.GetInt64(0), + UserId = reader.GetInt64(1), + ExpiresAt = reader.GetFieldValue(2), + UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + // Hash new password and update user + var passwordHash = HashPassword(newPassword); + await db.ExecuteNonQueryAsync( + @"UPDATE app.users + SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL + WHERE id = @userId", + new { userId = tokenRow.UserId, passwordHash } + ); + + // Mark token as used + await db.ExecuteNonQueryAsync( + "UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId", + new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow } + ); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Password reset failed: {ex.Message}"); + } + } } diff --git a/Media.JoshHeaps.Net/Services/EmailService.cs b/Media.JoshHeaps.Net/Services/EmailService.cs index 29d7970..c39b980 100644 --- a/Media.JoshHeaps.Net/Services/EmailService.cs +++ b/Media.JoshHeaps.Net/Services/EmailService.cs @@ -107,7 +107,7 @@ If you didn't create an account, you can safely ignore this email. try { var appUrl = config["AppUrl"] ?? "http://localhost:5000"; - var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}"; + var resetUrl = $"{appUrl}/LoginHelp?token={resetToken}"; var message = new MimeMessage(); message.From.Add(new MailboxAddress( diff --git a/Media.JoshHeaps.Net/Services/MedicalAiService.cs b/Media.JoshHeaps.Net/Services/MedicalAiService.cs index 72158ed..9ddf4ac 100644 --- a/Media.JoshHeaps.Net/Services/MedicalAiService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalAiService.cs @@ -447,7 +447,7 @@ Only include fields you can confidently extract. Return ONLY the JSON object, no return null; } - _logger.LogError("claude CLI exited with code {ExitCode}: {Stderr}", process.ExitCode, stderr); + _logger.LogError("claude CLI exited with code {ExitCode}.\nStderr: {Stderr}\nStdout: {Stdout}", process.ExitCode, stderr, stdout); return null; } diff --git a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs index 0212d1d..4535444 100644 --- a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs @@ -6,12 +6,16 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, { // --- People --- - public async Task> GetPeopleAsync() + public async Task> GetPeopleAsync(long userId) { try { return await db.ExecuteListReaderAsync( - "SELECT id, name, date_of_birth, notes, created_at, updated_at FROM app.medical_people ORDER BY name", + @"SELECT mp.id, mp.name, mp.date_of_birth, mp.notes, mp.created_at, mp.updated_at + FROM app.medical_people mp + JOIN app.medical_people_access mpa ON mpa.person_id = mp.id + WHERE mpa.user_id = @userId + ORDER BY mp.name", reader => new MedicalPerson { Id = reader.GetInt64(0), @@ -20,7 +24,8 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Notes = reader.IsDBNull(3) ? null : reader.GetString(3), CreatedAt = reader.GetDateTime(4), UpdatedAt = reader.GetDateTime(5) - }); + }, + new { userId }); } catch (Exception ex) { @@ -29,12 +34,12 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task CreatePersonAsync(string name, DateTime? dateOfBirth = null, string? notes = null) + public async Task CreatePersonAsync(long userId, string name, DateTime? dateOfBirth = null, string? notes = null) { try { var now = DateTime.UtcNow; - return await db.ExecuteReaderAsync( + var person = await db.ExecuteReaderAsync( @"INSERT INTO app.medical_people (name, date_of_birth, notes, created_at, updated_at) VALUES (@name, @dateOfBirth, @notes, @createdAt, @updatedAt) RETURNING id, name, date_of_birth, notes, created_at, updated_at", @@ -48,6 +53,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, UpdatedAt = reader.GetDateTime(5) }, new { name, dateOfBirth, notes, createdAt = now, updatedAt = now }); + + if (person != null) + { + await db.ExecuteNonQueryAsync( + "INSERT INTO app.medical_people_access (person_id, user_id) VALUES (@personId, @userId) ON CONFLICT DO NOTHING", + new { personId = person.Id, userId }); + } + + return person; } catch (Exception ex) { @@ -56,6 +70,109 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } + // --- People Access --- + + public async Task HasAccessToPersonAsync(long userId, long personId) + { + try + { + return await db.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.medical_people_access WHERE user_id = @userId AND person_id = @personId)", + new { userId, personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check person access"); + return false; + } + } + + public async Task GrantAccessAsync(long personId, long targetUserId) + { + try + { + await db.ExecuteNonQueryAsync( + "INSERT INTO app.medical_people_access (person_id, user_id) VALUES (@personId, @userId) ON CONFLICT DO NOTHING", + new { personId, userId = targetUserId }); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to grant person access"); + return false; + } + } + + public async Task RevokeAccessAsync(long personId, long targetUserId) + { + try + { + var count = await db.ExecuteAsync( + "SELECT COUNT(*) FROM app.medical_people_access WHERE person_id = @personId", + new { personId }); + if (count <= 1) + return false; + + await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_people_access WHERE person_id = @personId AND user_id = @userId", + new { personId, userId = targetUserId }); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to revoke person access"); + return false; + } + } + + public async Task> GetPeopleAccessAsync(long personId) + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT u.id, u.username FROM app.users u + JOIN app.medical_people_access mpa ON mpa.user_id = u.id + WHERE mpa.person_id = @personId + ORDER BY u.username", + reader => new PersonAccessUser + { + Id = reader.GetInt64(0), + Username = reader.GetString(1) + }, + new { personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get person access list"); + return []; + } + } + + public async Task GetPersonIdForResourceAsync(string resourceType, long resourceId) + { + try + { + var sql = resourceType switch + { + "document" => "SELECT person_id FROM app.medical_documents WHERE id = @id", + "condition" => "SELECT person_id FROM app.medical_conditions WHERE id = @id", + "prescription" => "SELECT person_id FROM app.medical_prescriptions WHERE id = @id", + "pickup" => "SELECT p.person_id FROM app.medical_prescription_pickups pk JOIN app.medical_prescriptions p ON pk.prescription_id = p.id WHERE pk.id = @id", + "provider" => "SELECT person_id FROM app.medical_billing_providers WHERE id = @id", + "provider-payment" => "SELECT bp.person_id FROM app.medical_provider_payments pp JOIN app.medical_billing_providers bp ON pp.provider_id = bp.id WHERE pp.id = @id", + "bill" => "SELECT person_id FROM app.medical_bills WHERE id = @id", + "bill-charge" => "SELECT b.person_id FROM app.medical_bill_charges bc JOIN app.medical_bills b ON bc.bill_id = b.id WHERE bc.id = @id", + _ => throw new ArgumentException($"Unknown resource type: {resourceType}") + }; + return await db.ExecuteAsync(sql, new { id = resourceId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get person ID for {ResourceType} {ResourceId}", resourceType, resourceId); + return null; + } + } + // --- Documents --- public async Task SaveDocumentAsync(long personId, IFormFile file, string? title = null, string? description = null, DateTime? documentDate = null, string? classification = null) diff --git a/Media.JoshHeaps.Net/wwwroot/css/auth.css b/Media.JoshHeaps.Net/wwwroot/css/auth.css index e7c7115..70fad61 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/auth.css +++ b/Media.JoshHeaps.Net/wwwroot/css/auth.css @@ -152,6 +152,29 @@ display: block; } +.form-options { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.form-options .checkbox-wrapper { + margin-bottom: 0; +} + +.forgot-password-link { + color: var(--text-secondary); + font-size: 13px; + text-decoration: none; + transition: color 0.2s ease; +} + +.forgot-password-link:hover { + color: var(--accent-primary); + text-decoration: underline; +} + .checkbox-wrapper { display: flex; align-items: center; diff --git a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css index 189a697..43b72e6 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css +++ b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css @@ -323,6 +323,39 @@ color: #fff; } +.person-pill-row { + display: flex; + align-items: center; + gap: 4px; +} + +.person-pill-row .person-pill { + flex: 1; + min-width: 0; +} + +.person-share-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.person-share-btn:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); + background: var(--bg-tertiary); +} + /* ======================== */ /* Form Inputs */ /* ======================== */ diff --git a/Media.JoshHeaps.Net/wwwroot/js/auth.js b/Media.JoshHeaps.Net/wwwroot/js/auth.js index a36a8d3..f44f27a 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/auth.js +++ b/Media.JoshHeaps.Net/wwwroot/js/auth.js @@ -336,9 +336,145 @@ function initRegisterForm() { }); } +// Password reset form validation +function initPasswordResetForm() { + const form = document.getElementById('resetPasswordForm'); + if (!form) return; + + const passwordInput = document.getElementById('newPassword'); + const confirmPasswordInput = document.getElementById('confirmPassword'); + + if (passwordInput) { + passwordInput.addEventListener('input', function() { + checkPasswordStrength(this.value); + if (this.value && validatePassword(this.value)) { + clearError(this); + } + + if (confirmPasswordInput && confirmPasswordInput.value) { + if (confirmPasswordInput.value === this.value) { + clearError(confirmPasswordInput); + } else { + showError(confirmPasswordInput, 'Passwords do not match'); + } + } + }); + + passwordInput.addEventListener('blur', function() { + if (!this.value) { + showError(this, 'Password is required'); + } else if (!validatePassword(this.value)) { + showError(this, 'Password must be at least 8 characters'); + } else { + clearError(this); + } + }); + } + + if (confirmPasswordInput) { + confirmPasswordInput.addEventListener('input', function() { + if (passwordInput && this.value === passwordInput.value) { + clearError(this); + } + }); + + confirmPasswordInput.addEventListener('blur', function() { + if (!this.value) { + showError(this, 'Please confirm your password'); + } else if (passwordInput && this.value !== passwordInput.value) { + showError(this, 'Passwords do not match'); + } else { + clearError(this); + } + }); + } + + form.addEventListener('submit', function(e) { + e.preventDefault(); + + let isValid = true; + + if (!passwordInput.value) { + showError(passwordInput, 'Password is required'); + isValid = false; + } else if (!validatePassword(passwordInput.value)) { + showError(passwordInput, 'Password must be at least 8 characters'); + isValid = false; + } else { + clearError(passwordInput); + } + + if (!confirmPasswordInput.value) { + showError(confirmPasswordInput, 'Please confirm your password'); + isValid = false; + } else if (confirmPasswordInput.value !== passwordInput.value) { + showError(confirmPasswordInput, 'Passwords do not match'); + isValid = false; + } else { + clearError(confirmPasswordInput); + } + + if (isValid) { + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Resetting...'; + form.submit(); + } + }); +} + +// Request reset form validation +function initRequestResetForm() { + const form = document.getElementById('requestResetForm'); + if (!form) return; + + const emailInput = document.getElementById('email'); + + if (emailInput) { + emailInput.addEventListener('blur', function() { + if (!this.value.trim()) { + showError(this, 'Email is required'); + } else if (!validateEmail(this.value)) { + showError(this, 'Please enter a valid email address'); + } else { + clearError(this); + } + }); + + emailInput.addEventListener('input', function() { + if (this.value.trim() && validateEmail(this.value)) { + clearError(this); + } + }); + } + + form.addEventListener('submit', function(e) { + e.preventDefault(); + + if (!emailInput.value.trim()) { + showError(emailInput, 'Email is required'); + return; + } + + if (!validateEmail(emailInput.value)) { + showError(emailInput, 'Please enter a valid email address'); + return; + } + + clearError(emailInput); + + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Sending...'; + form.submit(); + }); +} + // Initialize on page load document.addEventListener('DOMContentLoaded', function() { initPasswordToggles(); initLoginForm(); initRegisterForm(); + initPasswordResetForm(); + initRequestResetForm(); }); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js index f426d21..a0d2429 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js @@ -11,7 +11,12 @@ app.renderPeople = function () { const container = document.getElementById('peopleList'); container.innerHTML = state.people.map(p => - `` + `
+ + +
` ).join(''); }; @@ -61,5 +66,86 @@ app.switchMainTab('documents'); }; + // --- Share Access --- + + app.openShareModal = async function (personId, event) { + event.stopPropagation(); + state.sharePersonId = personId; + document.getElementById('shareAccessOverlay').style.display = ''; + document.getElementById('shareUsername').value = ''; + await app.loadShareAccess(personId); + }; + + app.closeShareModal = function (event) { + if (event && event.target !== event.currentTarget) return; + document.getElementById('shareAccessOverlay').style.display = 'none'; + state.sharePersonId = null; + }; + + app.loadShareAccess = async function (personId) { + const container = document.getElementById('shareAccessList'); + container.innerHTML = '
Loading...
'; + + const res = await fetch(`${app.API}/people/${personId}/access`); + if (!res.ok) { + container.innerHTML = '
Failed to load access list
'; + return; + } + + const users = await res.json(); + state.shareAccessUsers = users; + + if (users.length === 0) { + container.innerHTML = '
No users have access
'; + return; + } + + container.innerHTML = users.map(u => + `
+ ${app.escapeHtml(u.username)} + +
` + ).join(''); + }; + + app.grantAccess = async function () { + const input = document.getElementById('shareUsername'); + const username = input.value.trim(); + if (!username || !state.sharePersonId) return; + + const res = await fetch(`${app.API}/people/${state.sharePersonId}/access`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }) + }); + + if (!res.ok) { + const err = await res.json(); + alert(err.error || 'Failed to grant access'); + return; + } + + input.value = ''; + await app.loadShareAccess(state.sharePersonId); + }; + + app.revokeAccess = async function (personId, targetUserId) { + const res = await fetch(`${app.API}/people/${personId}/access/${targetUserId}`, { + method: 'DELETE' + }); + + if (!res.ok) { + const err = await res.json(); + alert(err.error || 'Failed to revoke access'); + return; + } + + await app.loadShareAccess(personId); + }; + window.medDocsSelectPerson = (id) => app.selectPerson(id); + window.medDocsOpenShareModal = (id, event) => app.openShareModal(id, event); + window.medDocsCloseShareModal = (event) => app.closeShareModal(event); + window.medDocsGrantAccess = () => app.grantAccess(); + window.medDocsRevokeAccess = (personId, userId) => app.revokeAccess(personId, userId); })(MedDocs);