mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 00:38:44 +00:00
Latest
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@ var dbName = Environment.GetEnvironmentVariable("MYSQL_DATABASE") ?? "winstudent
|
|||||||
var dbUser = Environment.GetEnvironmentVariable("MYSQL_USER") ?? "root";
|
var dbUser = Environment.GetEnvironmentVariable("MYSQL_USER") ?? "root";
|
||||||
var dbPassword = Environment.GetEnvironmentVariable("MYSQL_PASSWORD") ?? "";
|
var dbPassword = Environment.GetEnvironmentVariable("MYSQL_PASSWORD") ?? "";
|
||||||
builder.Configuration["ConnectionStrings:DefaultConnection"] =
|
builder.Configuration["ConnectionStrings:DefaultConnection"] =
|
||||||
$"Server={dbServer};Port={dbPort};Database={dbName};Uid={dbUser};Pwd={dbPassword};";
|
$"Server={dbServer};Port={dbPort};Database={dbName};Uid={dbUser};Pwd={dbPassword};SslMode=Disabled;";
|
||||||
|
|
||||||
// Override JWT key from .env if present
|
// Override JWT key from .env if present
|
||||||
var envJwtKey = Environment.GetEnvironmentVariable("JWT_KEY");
|
var envJwtKey = Environment.GetEnvironmentVariable("JWT_KEY");
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
|
||||||
<PackageReference Include="MySql.Data" Version="8.4.0" />
|
<PackageReference Include="MySql.Data" Version="9.6.0" />
|
||||||
|
<PackageReference Include="MySqlBackup.NET" Version="2.7.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using WinStudentGoalTracker.BaseClasses;
|
||||||
|
using WinStudentGoalTracker.DataAccess;
|
||||||
|
using WinStudentGoalTracker.Models;
|
||||||
|
using WinStudentGoalTracker.Services;
|
||||||
|
|
||||||
|
namespace WinStudentGoalTracker.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AdminController : BaseController
|
||||||
|
{
|
||||||
|
private readonly AdminRepository _adminRepo = new();
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns the district ID for the current user by resolving the
|
||||||
|
// program→district FK relationship. The JWT carries program_id
|
||||||
|
// but not district_id; this method bridges that gap. This is a
|
||||||
|
// deliberate design choice: the JWT stays lean, and we derive
|
||||||
|
// the district from the program FK on each request.
|
||||||
|
// *****************************************************************
|
||||||
|
private async Task<(Guid districtId, ActionResult? error)> GetDistrictForCurrentUser(Guid programId)
|
||||||
|
{
|
||||||
|
var districtId = await _adminRepo.GetDistrictIdForProgramAsync(programId);
|
||||||
|
if (!districtId.HasValue)
|
||||||
|
{
|
||||||
|
return (Guid.Empty, NotFound(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "District not found for the current program."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return (districtId.Value, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Programs *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns all programs for the current user's district.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpGet("programs")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<List<object>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ResponseResult<List<object>>>> GetPrograms()
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
|
||||||
|
if (districtError is not null) return districtError;
|
||||||
|
|
||||||
|
var programs = await _adminRepo.GetProgramsByDistrictAsync(districtId);
|
||||||
|
var result = programs.Select(p => new
|
||||||
|
{
|
||||||
|
programId = p.IdProgram,
|
||||||
|
name = p.Name,
|
||||||
|
description = p.Description,
|
||||||
|
createdAt = p.CreatedAt
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<List<object>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Programs retrieved.",
|
||||||
|
Data = result.Cast<object>().ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Creates a new program under the current user's district.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpPost("programs")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<ResponseResult<object>>> CreateProgram([FromBody] AdminCreateProgramDto dto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Program name is required."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
|
||||||
|
if (districtError is not null) return districtError;
|
||||||
|
|
||||||
|
var newProgramId = Guid.NewGuid();
|
||||||
|
var created = await _adminRepo.CreateProgramAsync(newProgramId, districtId, dto.Name, dto.Description);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Program created.",
|
||||||
|
Data = new
|
||||||
|
{
|
||||||
|
programId = newProgramId,
|
||||||
|
name = dto.Name,
|
||||||
|
description = dto.Description
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Updates a program's name and description.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpPut("programs/{idProgram:guid}")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ResponseResult<object>>> UpdateProgram(Guid idProgram, [FromBody] AdminCreateProgramDto dto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Program name is required."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
// Verify the program belongs to the user's district
|
||||||
|
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
|
||||||
|
if (districtError is not null) return districtError;
|
||||||
|
|
||||||
|
var targetDistrictId = await _adminRepo.GetDistrictIdForProgramAsync(idProgram);
|
||||||
|
if (!targetDistrictId.HasValue || targetDistrictId.Value != districtId)
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Program not found in your district."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _adminRepo.UpdateProgramAsync(idProgram, dto.Name, dto.Description);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Program updated."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Users *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns all active users across programs in the user's district.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpGet("users")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<List<object>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ResponseResult<List<object>>>> GetUsers()
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
|
||||||
|
if (districtError is not null) return districtError;
|
||||||
|
|
||||||
|
var users = await _adminRepo.GetUsersByDistrictAsync(districtId);
|
||||||
|
var result = users.Select(u => new
|
||||||
|
{
|
||||||
|
userId = u.IdUser,
|
||||||
|
email = u.Email,
|
||||||
|
name = u.Name,
|
||||||
|
programId = u.IdProgram,
|
||||||
|
programName = u.ProgramName,
|
||||||
|
roleId = u.IdRole,
|
||||||
|
roleName = u.RoleName,
|
||||||
|
createdAt = u.CreatedAt
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<List<object>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Users retrieved.",
|
||||||
|
Data = result.Cast<object>().ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Creates a new user and assigns them to a program with a role.
|
||||||
|
// District admins cannot assign the super_admin role.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpPost("users")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<ResponseResult<object>>> CreateUser([FromBody] AdminCreateUserDto dto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Email) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.Name) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.Password) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.ProgramId) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.RoleId))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Email, name, password, program, and role are required."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Guid.TryParse(dto.ProgramId, out var targetProgramId) ||
|
||||||
|
!Guid.TryParse(dto.RoleId, out var roleId))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid program or role ID."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
// Verify the target program belongs to the user's district
|
||||||
|
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
|
||||||
|
if (districtError is not null) return districtError;
|
||||||
|
|
||||||
|
var targetDistrictId = await _adminRepo.GetDistrictIdForProgramAsync(targetProgramId);
|
||||||
|
if (!targetDistrictId.HasValue || targetDistrictId.Value != districtId)
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Program not found in your district."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent district_admin from assigning super_admin role
|
||||||
|
var allRoles = await _adminRepo.GetAllRolesAsync();
|
||||||
|
var selectedRole = allRoles.FirstOrDefault(r => r.IdRole == roleId);
|
||||||
|
if (selectedRole == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Invalid role."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selectedRole.InternalName == UserRoles.SuperAdmin && role != UserRoles.SuperAdmin)
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Only super admins can assign the super admin role."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate email
|
||||||
|
if (await _adminRepo.EmailExistsAsync(dto.Email))
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "An account with this email already exists."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
var newUserId = Guid.NewGuid();
|
||||||
|
var (hash, salt) = PasswordHasher.HashPassword(dto.Password);
|
||||||
|
await _adminRepo.CreateUserAsync(newUserId, dto.Email, dto.Name, hash, salt);
|
||||||
|
|
||||||
|
// Assign to program with role
|
||||||
|
await _adminRepo.AssignUserToProgramAsync(newUserId, targetProgramId, roleId);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "User created and assigned to program.",
|
||||||
|
Data = new { userId = newUserId, email = dto.Email, name = dto.Name }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Roles *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns all available roles. Excludes super_admin for
|
||||||
|
// non-super-admin users.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpGet("roles")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<List<object>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ResponseResult<List<object>>>> GetRoles()
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var roles = await _adminRepo.GetAllRolesAsync();
|
||||||
|
|
||||||
|
// District admins cannot see/assign the super_admin role
|
||||||
|
if (role != UserRoles.SuperAdmin)
|
||||||
|
{
|
||||||
|
roles = roles.Where(r => r.InternalName != UserRoles.SuperAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = roles.Select(r => new
|
||||||
|
{
|
||||||
|
roleId = r.IdRole,
|
||||||
|
name = r.Name,
|
||||||
|
internalName = r.InternalName,
|
||||||
|
description = r.Description
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<List<object>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Roles retrieved.",
|
||||||
|
Data = result.Cast<object>().ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Backup *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Exports the entire MySQL database as a .sql dump file using
|
||||||
|
// MySqlBackup.NET. Runs in-process with no shell-out; uses a
|
||||||
|
// single-transaction snapshot for InnoDB consistency (no locking).
|
||||||
|
// Restricted to district/super admins.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpGet("backup")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status500InternalServerError)]
|
||||||
|
public IActionResult BackupDatabase()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = new MySql.Data.MySqlClient.MySqlConnection(DatabaseManager.ConnectionString);
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = new MySql.Data.MySqlClient.MySqlCommand { Connection = conn };
|
||||||
|
var backup = new MySql.Data.MySqlClient.MySqlBackup(cmd);
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
backup.ExportToMemoryStream(memoryStream);
|
||||||
|
|
||||||
|
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||||
|
var fileName = $"winstudentgoaltracker_backup_{timestamp}.sql";
|
||||||
|
|
||||||
|
return File(memoryStream.ToArray(), "application/sql", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = $"Backup failed: {ex.Message}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -452,4 +452,76 @@ public class AuthController : BaseController
|
|||||||
Message = "Password set successfully."
|
Message = "Password set successfully."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Self-service registration: creates a school district, the user's
|
||||||
|
// first program, and the user account. The user is assigned as
|
||||||
|
// district_admin on the new program, giving them a program_id for
|
||||||
|
// their JWT and access to create more programs from the admin panel.
|
||||||
|
// *****************************************************************
|
||||||
|
[HttpPost("Register")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<ResponseResult<object>>> Register([FromBody] RegisterDto dto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.Email) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.Password) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.Name) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.DistrictName) ||
|
||||||
|
string.IsNullOrWhiteSpace(dto.ProgramName))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Email, password, name, district name, and program name are required."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminRepo = new AdminRepository();
|
||||||
|
|
||||||
|
// Check for duplicate email
|
||||||
|
if (await adminRepo.EmailExistsAsync(dto.Email))
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "An account with this email already exists."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the district_admin role
|
||||||
|
var districtAdminRole = await adminRepo.GetRoleByInternalNameAsync(UserRoles.DistrictAdmin);
|
||||||
|
if (districtAdminRole == null)
|
||||||
|
{
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "System configuration error: district_admin role not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the school district
|
||||||
|
var districtId = Guid.NewGuid();
|
||||||
|
await adminRepo.CreateDistrictAsync(districtId, dto.DistrictName, dto.DistrictContactEmail);
|
||||||
|
|
||||||
|
// Create the user with hashed password
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var (hash, salt) = PasswordHasher.HashPassword(dto.Password);
|
||||||
|
await adminRepo.CreateUserAsync(userId, dto.Email, dto.Name, hash, salt);
|
||||||
|
|
||||||
|
// Create the user's first program under the new district.
|
||||||
|
// This gives the district_admin a program_id for their JWT,
|
||||||
|
// enabling them to log in and manage the district from the admin panel.
|
||||||
|
var programId = Guid.NewGuid();
|
||||||
|
await adminRepo.CreateProgramAsync(programId, districtId, dto.ProgramName, dto.ProgramDescription);
|
||||||
|
|
||||||
|
// Assign the user as district_admin on the new program
|
||||||
|
await adminRepo.AssignUserToProgramAsync(userId, programId, districtAdminRole.IdRole, isPrimary: true);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Registration successful. You can now log in."
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class AdminCreateProgramDto
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class AdminCreateUserDto
|
||||||
|
{
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? ProgramId { get; set; }
|
||||||
|
public string? RoleId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class RegisterDto
|
||||||
|
{
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? DistrictName { get; set; }
|
||||||
|
public string? DistrictContactEmail { get; set; }
|
||||||
|
public string? ProgramName { get; set; }
|
||||||
|
public string? ProgramDescription { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class dbDistrictUserRow
|
||||||
|
{
|
||||||
|
public Guid IdUser { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public Guid IdProgram { get; set; }
|
||||||
|
public string? ProgramName { get; set; }
|
||||||
|
public Guid IdRole { get; set; }
|
||||||
|
public string? RoleName { get; set; }
|
||||||
|
public string? RoleInternalName { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class dbProgramRow
|
||||||
|
{
|
||||||
|
public Guid IdProgram { get; set; }
|
||||||
|
public Guid IdSchoolDistrict { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class dbRoleRow
|
||||||
|
{
|
||||||
|
public Guid IdRole { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? InternalName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class AdminRepository
|
||||||
|
{
|
||||||
|
private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString);
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns the district ID for a given program ID. Used to resolve
|
||||||
|
// the user's district from their JWT program_id claim, since the
|
||||||
|
// JWT does not carry district_id directly. The program→district
|
||||||
|
// FK relationship is the source of truth.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<Guid?> GetDistrictIdForProgramAsync(Guid programId)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var row = await db.QuerySingleOrDefaultAsync<dbProgramRow>(
|
||||||
|
"sp_Program_GetById",
|
||||||
|
new { p_id_program = programId.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return row?.IdSchoolDistrict;
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Creates a new school district and returns the created row.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<dynamic?> CreateDistrictAsync(Guid districtId, string name, string? contactEmail)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QuerySingleOrDefaultAsync<dynamic>(
|
||||||
|
"sp_SchoolDistrict_Insert",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_school_district = districtId.ToString(),
|
||||||
|
p_name = name,
|
||||||
|
p_contact_email = contactEmail
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Creates a new user and returns the created row.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<dynamic?> CreateUserAsync(Guid userId, string email, string name, string passwordHash, string passwordSalt)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QuerySingleOrDefaultAsync<dynamic>(
|
||||||
|
"sp_User_Insert",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_user = userId.ToString(),
|
||||||
|
p_email = email,
|
||||||
|
p_name = name,
|
||||||
|
p_password_hash = passwordHash,
|
||||||
|
p_password_salt = passwordSalt
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Assigns a user to a program with a given role.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<bool> AssignUserToProgramAsync(Guid userId, Guid programId, Guid roleId, bool isPrimary = true)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var rowsAffected = await db.ExecuteScalarAsync<int>(
|
||||||
|
"sp_UserProgram_Insert",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_user_program = Guid.NewGuid().ToString(),
|
||||||
|
p_id_user = userId.ToString(),
|
||||||
|
p_id_program = programId.ToString(),
|
||||||
|
p_id_role = roleId.ToString(),
|
||||||
|
p_is_primary = isPrimary ? 1 : 0
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rowsAffected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns all programs belonging to a given district.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<IEnumerable<dbProgramRow>> GetProgramsByDistrictAsync(Guid districtId)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QueryAsync<dbProgramRow>(
|
||||||
|
"sp_Program_GetByDistrictId",
|
||||||
|
new { p_id_school_district = districtId.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Creates a new program under a given district.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<dbProgramRow?> CreateProgramAsync(Guid programId, Guid districtId, string name, string? description)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QuerySingleOrDefaultAsync<dbProgramRow>(
|
||||||
|
"sp_Program_Insert",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_program = programId.ToString(),
|
||||||
|
p_id_school_district = districtId.ToString(),
|
||||||
|
p_name = name,
|
||||||
|
p_description = description
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Updates an existing program's name and description.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<bool> UpdateProgramAsync(Guid programId, string name, string? description)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var rowsAffected = await db.ExecuteScalarAsync<int>(
|
||||||
|
"sp_Program_Update",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_program = programId.ToString(),
|
||||||
|
p_name = name,
|
||||||
|
p_description = description
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rowsAffected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns all active users across all programs in a district.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<IEnumerable<dbDistrictUserRow>> GetUsersByDistrictAsync(Guid districtId)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QueryAsync<dbDistrictUserRow>(
|
||||||
|
"sp_User_GetByDistrictId",
|
||||||
|
new { p_id_school_district = districtId.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns all available roles.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<IEnumerable<dbRoleRow>> GetAllRolesAsync()
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QueryAsync<dbRoleRow>(
|
||||||
|
"sp_Role_GetAll",
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns a role by its internal_name (e.g. "district_admin").
|
||||||
|
// Queries the role table directly since no SP exists for this.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<dbRoleRow?> GetRoleByInternalNameAsync(string internalName)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
return await db.QuerySingleOrDefaultAsync<dbRoleRow>(
|
||||||
|
"SELECT id_role AS IdRole, name AS Name, internal_name AS InternalName, description AS Description FROM role WHERE internal_name = @internalName",
|
||||||
|
new { internalName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Checks if a user with the given email already exists.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<bool> EmailExistsAsync(string email)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var count = await db.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM `user` WHERE email = @email",
|
||||||
|
new { email });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Program_GetByDistrictId`(
|
||||||
|
IN p_id_school_district CHAR(36)
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT
|
||||||
|
id_program,
|
||||||
|
id_school_district,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_at
|
||||||
|
FROM program
|
||||||
|
WHERE id_school_district = p_id_school_district
|
||||||
|
ORDER BY name;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Role_GetAll`()
|
||||||
|
BEGIN
|
||||||
|
SELECT
|
||||||
|
id_role,
|
||||||
|
name,
|
||||||
|
internal_name,
|
||||||
|
description
|
||||||
|
FROM role
|
||||||
|
ORDER BY name;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_UserProgram_Insert`(
|
||||||
|
IN p_id_user_program CHAR(36),
|
||||||
|
IN p_id_user CHAR(36),
|
||||||
|
IN p_id_program CHAR(36),
|
||||||
|
IN p_id_role CHAR(36),
|
||||||
|
IN p_is_primary TINYINT
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO user_program
|
||||||
|
(
|
||||||
|
id_user_program,
|
||||||
|
id_user,
|
||||||
|
id_program,
|
||||||
|
id_role,
|
||||||
|
is_primary,
|
||||||
|
status,
|
||||||
|
joined_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
p_id_user_program,
|
||||||
|
p_id_user,
|
||||||
|
p_id_program,
|
||||||
|
p_id_role,
|
||||||
|
p_is_primary,
|
||||||
|
'active',
|
||||||
|
UTC_TIMESTAMP()
|
||||||
|
);
|
||||||
|
SELECT ROW_COUNT() AS rows_affected;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_GetByDistrictId`(
|
||||||
|
IN p_id_school_district CHAR(36)
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT DISTINCT
|
||||||
|
u.id_user,
|
||||||
|
u.email,
|
||||||
|
u.name,
|
||||||
|
u.created_at,
|
||||||
|
up.id_program,
|
||||||
|
p.name AS program_name,
|
||||||
|
r.id_role,
|
||||||
|
r.name AS role_name,
|
||||||
|
r.internal_name AS role_internal_name
|
||||||
|
FROM `user` u
|
||||||
|
INNER JOIN user_program up ON up.id_user = u.id_user
|
||||||
|
INNER JOIN program p ON p.id_program = up.id_program
|
||||||
|
INNER JOIN role r ON r.id_role = up.id_role
|
||||||
|
WHERE p.id_school_district = p_id_school_district
|
||||||
|
AND up.status = 'active'
|
||||||
|
ORDER BY u.name, p.name;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_Insert`(
|
||||||
|
IN p_id_user CHAR(36),
|
||||||
|
IN p_email VARCHAR(255),
|
||||||
|
IN p_name VARCHAR(255),
|
||||||
|
IN p_password_hash VARCHAR(255),
|
||||||
|
IN p_password_salt VARCHAR(255)
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO `user`
|
||||||
|
(
|
||||||
|
id_user,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
password_hash,
|
||||||
|
password_salt,
|
||||||
|
password_updated_at,
|
||||||
|
failed_login_attempts,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
p_id_user,
|
||||||
|
p_email,
|
||||||
|
p_name,
|
||||||
|
p_password_hash,
|
||||||
|
p_password_salt,
|
||||||
|
UTC_TIMESTAMP(),
|
||||||
|
0,
|
||||||
|
UTC_TIMESTAMP()
|
||||||
|
);
|
||||||
|
SELECT
|
||||||
|
id_user,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
created_at
|
||||||
|
FROM `user`
|
||||||
|
WHERE id_user = p_id_user
|
||||||
|
LIMIT 1;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
+6
-6
@@ -1151,7 +1151,7 @@
|
|||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<strong>Frontend</strong>
|
<strong>Frontend</strong>
|
||||||
Angular 20
|
Angular 20.1.5
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<strong>Backend</strong>
|
<strong>Backend</strong>
|
||||||
@@ -1218,7 +1218,7 @@
|
|||||||
<section id="hosting">
|
<section id="hosting">
|
||||||
<h2>4. Recommended Hosting</h2>
|
<h2>4. Recommended Hosting</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The entire application is currently hosted online with Hetzner (www.hetzner.com), on a plan donated by team members. The team has committed to hosting for at least the next two years, and if/when that changes, will help the partner transition to the hosting platform of their choice. </li>
|
<li>The entire application is currently hosted online with Hetzner (www.hetzner.com), on a plan donated by team members. The team has committed to hosting for at least the next two years, and if/when that changes, will help the partner transition to the hosting platform of their choice. The team recommends staying with Hetzner based on their reliability and low cost.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1307,9 +1307,9 @@ JWT_EXPIRATION=3600</code></pre>
|
|||||||
|
|
||||||
<h3>Form Factor Analysis</h3>
|
<h3>Form Factor Analysis</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Mobile Portrait: Fully responsive and functional</li>
|
<li>Mobile Portrait: Fully responsive and functional - minimal UI and streamlined functionality for robust field use.</li>
|
||||||
<li>Mobile Landscape: Improved readability and layout</li>
|
<li>Mobile Landscape: Improved readability and layout - same features and user story as mobile portrait</li>
|
||||||
<li>Desktop: Optimal user experience</li>
|
<li>Desktop: Full-featured user experience, with application configuration, administrator and edit/delete functionality</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>UX Observations</h3>
|
<h3>UX Observations</h3>
|
||||||
@@ -1329,7 +1329,7 @@ JWT_EXPIRATION=3600</code></pre>
|
|||||||
|
|
||||||
<section id="sustain">
|
<section id="sustain">
|
||||||
<h2>13. Sustainability Considerations</h2>
|
<h2>13. Sustainability Considerations</h2>
|
||||||
<p>Docker deployment, free-tier hosting, and modular design support long-term maintainability.</p>
|
<p>Docker deployment, very inexpensive hosting, and modular design support long-term maintainability. Addcitionally, the use of free, off-the-shelf technology choices (Angular, C#, MySQL) contribute a sustainable project tech stack. </p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="appendix-a">
|
<section id="appendix-a">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { Login } from './shared/pages/login/login';
|
import { Login } from './shared/pages/login/login';
|
||||||
|
import { Register } from './shared/pages/register/register';
|
||||||
import { PlatformService } from './shared/services/platform.service';
|
import { PlatformService } from './shared/services/platform.service';
|
||||||
import { authGuard } from './shared/guards/auth.guard';
|
import { authGuard } from './shared/guards/auth.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: 'login', component: Login },
|
{ path: 'login', component: Login },
|
||||||
|
{ path: 'register', component: Register },
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
|
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
|
||||||
|
|||||||
@@ -19,6 +19,29 @@
|
|||||||
<input class="field-input" type="date" [(ngModel)]="form.targetCompletionDate" />
|
<input class="field-input" type="date" [(ngModel)]="form.targetCompletionDate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (isEditMode) {
|
||||||
|
<hr class="close-divider" />
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Close Date</label>
|
||||||
|
<input class="field-input" type="date" [(ngModel)]="closeDate" />
|
||||||
|
</div>
|
||||||
|
@if (closeDate) {
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-check">
|
||||||
|
<input type="checkbox" [(ngModel)]="achieved" />
|
||||||
|
Goal achieved
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (!achieved) {
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Close Notes</label>
|
||||||
|
<textarea class="field-input field-textarea" [(ngModel)]="closeNotes"
|
||||||
|
placeholder="Reason the goal was closed without being achieved..."></textarea>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (errorMessage()) {
|
@if (errorMessage()) {
|
||||||
<p class="error">{{ errorMessage() }}</p>
|
<p class="error">{{ errorMessage() }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,24 @@
|
|||||||
/* Inherits all styles from modal-shell via ::ng-deep */
|
/* Inherits all styles from modal-shell via ::ng-deep */
|
||||||
|
|
||||||
|
:host ::ng-deep {
|
||||||
|
.close-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export class GoalModal {
|
|||||||
targetCompletionDate: null,
|
targetCompletionDate: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Close-goal fields — only used in edit mode.
|
||||||
|
protected closeDate: string | null = null;
|
||||||
|
protected achieved = false;
|
||||||
|
protected closeNotes = '';
|
||||||
|
|
||||||
protected get isEditMode(): boolean {
|
protected get isEditMode(): boolean {
|
||||||
return !!this.goal();
|
return !!this.goal();
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,11 @@ export class GoalModal {
|
|||||||
this.form.targetCompletionDate = existing.targetCompletionDate
|
this.form.targetCompletionDate = existing.targetCompletionDate
|
||||||
? existing.targetCompletionDate.substring(0, 10)
|
? existing.targetCompletionDate.substring(0, 10)
|
||||||
: null;
|
: null;
|
||||||
|
this.closeDate = existing.closeDate
|
||||||
|
? existing.closeDate.substring(0, 10)
|
||||||
|
: null;
|
||||||
|
this.achieved = existing.achieved ?? false;
|
||||||
|
this.closeNotes = existing.closeNotes ?? '';
|
||||||
} else {
|
} else {
|
||||||
// Add mode — pre-fill target date from IEP if available
|
// Add mode — pre-fill target date from IEP if available
|
||||||
const iepDate = this.nextIepDate?.();
|
const iepDate = this.nextIepDate?.();
|
||||||
@@ -89,6 +99,9 @@ export class GoalModal {
|
|||||||
description: this.form.description,
|
description: this.form.description,
|
||||||
baseline: this.form.baseline,
|
baseline: this.form.baseline,
|
||||||
targetCompletionDate: this.form.targetCompletionDate,
|
targetCompletionDate: this.form.targetCompletionDate,
|
||||||
|
closeDate: this.closeDate || null,
|
||||||
|
achieved: this.closeDate ? this.achieved : null,
|
||||||
|
closeNotes: this.closeDate && !this.achieved ? this.closeNotes || null : null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.isSubmitting.set(false);
|
this.isSubmitting.set(false);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
[message]="'Delete \u0022' + (deletingBenchmark()!.shortName || deletingBenchmark()!.benchmark) + '\u0022? This cannot be undone.'"
|
[message]="'Delete \u0022' + (deletingBenchmark()!.shortName || deletingBenchmark()!.benchmark) + '\u0022? This cannot be undone.'"
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
[destructive]="true"
|
[destructive]="true"
|
||||||
|
[doubleConfirm]="true"
|
||||||
(confirmed)="onDeleteBenchmarkConfirmed()"
|
(confirmed)="onDeleteBenchmarkConfirmed()"
|
||||||
(closed)="showDeleteBenchmarkConfirm.set(false)" />
|
(closed)="showDeleteBenchmarkConfirm.set(false)" />
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
<button class="goal-tab add-goal" (click)="onAddGoal()">+ Goal</button>
|
<button class="goal-tab add-goal" (click)="onAddGoal()">+ Goal</button>
|
||||||
@for (g of goals(); track g.goalId) {
|
@for (g of goals(); track g.goalId) {
|
||||||
<button class="goal-tab" [class.active]="selectedGoalId() === g.goalId || (selectedGoal()?.goalId === g.goalId)"
|
<button class="goal-tab" [class.active]="selectedGoalId() === g.goalId || (selectedGoal()?.goalId === g.goalId)"
|
||||||
|
[class.closed]="!!g.closeDate"
|
||||||
(click)="onSelectGoal(g.goalId)">
|
(click)="onSelectGoal(g.goalId)">
|
||||||
{{ g.category }}
|
{{ g.category }}
|
||||||
</button>
|
</button>
|
||||||
@@ -98,6 +100,11 @@
|
|||||||
@if (selectedGoal()!.targetCompletionDate) {
|
@if (selectedGoal()!.targetCompletionDate) {
|
||||||
<span class="goal-due">Due {{ formatDate(selectedGoal()!.targetCompletionDate) }}</span>
|
<span class="goal-due">Due {{ formatDate(selectedGoal()!.targetCompletionDate) }}</span>
|
||||||
}
|
}
|
||||||
|
@if (selectedGoal()!.closeDate) {
|
||||||
|
<span class="goal-status" [class.achieved]="selectedGoal()!.achieved">
|
||||||
|
{{ selectedGoal()!.achieved ? '✓ Achieved' : 'Closed' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
<button class="delete-goal-btn" (click)="onDeleteGoal()" aria-label="Delete goal" title="Delete goal">
|
<button class="delete-goal-btn" (click)="onDeleteGoal()" aria-label="Delete goal" title="Delete goal">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
|||||||
@@ -137,6 +137,28 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.goal-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #FEF3C7;
|
||||||
|
color: #92400E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-status.achieved {
|
||||||
|
background: #D1FAE5;
|
||||||
|
color: #065F46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-tab.closed {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-tab.closed.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-goal-btn {
|
.delete-goal-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Home } from './pages/home/home';
|
|||||||
import { Workspace } from './components/workspace/workspace';
|
import { Workspace } from './components/workspace/workspace';
|
||||||
import { Reports } from './components/reports/reports';
|
import { Reports } from './components/reports/reports';
|
||||||
import { StudentProgressReport } from './components/student-progress-report/student-progress-report';
|
import { StudentProgressReport } from './components/student-progress-report/student-progress-report';
|
||||||
|
import { Admin } from './pages/admin/admin';
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -14,6 +15,7 @@ export default [
|
|||||||
{ path: 'students/:studentId/goals/:goalId', component: Workspace },
|
{ path: 'students/:studentId/goals/:goalId', component: Workspace },
|
||||||
{ path: 'reports', component: Reports },
|
{ path: 'reports', component: Reports },
|
||||||
{ path: 'reports/student-progress', component: StudentProgressReport },
|
{ path: 'reports/student-progress', component: StudentProgressReport },
|
||||||
|
{ path: 'admin', component: Admin },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] satisfies Routes;
|
] satisfies Routes;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Administration</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<p class="error">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab" [class.active]="activeTab() === 'programs'" (click)="onSwitchTab('programs')">Programs</button>
|
||||||
|
<button class="tab" [class.active]="activeTab() === 'users'" (click)="onSwitchTab('users')">Users</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Programs Tab -->
|
||||||
|
@if (activeTab() === 'programs') {
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="action-btn" (click)="onAddProgram()">+ Add Program</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (programs().length === 0) {
|
||||||
|
<p class="empty">No programs yet. Create your first program to get started.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="list">
|
||||||
|
@for (program of programs(); track program.programId) {
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-item-info">
|
||||||
|
<div class="list-item-name">{{ program.name }}</div>
|
||||||
|
@if (program.description) {
|
||||||
|
<div class="list-item-meta">{{ program.description }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="edit-btn" (click)="onEditProgram(program)">Edit</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Users Tab -->
|
||||||
|
@if (activeTab() === 'users') {
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="action-btn" (click)="onAddUser()">+ Add User</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (users().length === 0) {
|
||||||
|
<p class="empty">No users yet.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="list">
|
||||||
|
@for (user of users(); track user.userId + user.programId) {
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-item-info">
|
||||||
|
<div class="list-item-name">{{ user.name }}</div>
|
||||||
|
<div class="list-item-meta">{{ user.email }} · {{ user.roleName }} · {{ user.programName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Database Section -->
|
||||||
|
<div class="section-divider"></div>
|
||||||
|
<h2 class="section-heading">Database</h2>
|
||||||
|
<div class="backup-row">
|
||||||
|
<button class="action-btn backup-btn" (click)="onBackupDatabase()" [disabled]="backingUp()">
|
||||||
|
@if (backingUp()) {
|
||||||
|
Backing up…
|
||||||
|
} @else {
|
||||||
|
Back Up Database
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (backupSuccess()) {
|
||||||
|
<span class="backup-success">{{ backupSuccess() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Program Modal -->
|
||||||
|
@if (showProgramModal()) {
|
||||||
|
<app-modal-shell [title]="editingProgram() ? 'Edit Program' : 'New Program'" (closed)="showProgramModal.set(false)">
|
||||||
|
<form (ngSubmit)="onSaveProgram()" class="modal-form">
|
||||||
|
<label>
|
||||||
|
Program Name
|
||||||
|
<input type="text" [(ngModel)]="programName" name="programName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Description
|
||||||
|
<textarea [(ngModel)]="programDescription" name="programDescription" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit">{{ editingProgram() ? 'Save' : 'Create' }}</button>
|
||||||
|
</form>
|
||||||
|
</app-modal-shell>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- User Modal -->
|
||||||
|
@if (showUserModal()) {
|
||||||
|
<app-modal-shell title="New User" (closed)="showUserModal.set(false)">
|
||||||
|
<form (ngSubmit)="onSaveUser()" class="modal-form">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" [(ngModel)]="userName" name="userName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" [(ngModel)]="userEmail" name="userEmail" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" [(ngModel)]="userPassword" name="userPassword" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Program
|
||||||
|
<select [(ngModel)]="userProgramId" name="userProgramId" required>
|
||||||
|
<option value="" disabled>Select a program</option>
|
||||||
|
@for (p of programs(); track p.programId) {
|
||||||
|
<option [value]="p.programId">{{ p.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Role
|
||||||
|
<select [(ngModel)]="userRoleId" name="userRoleId" required>
|
||||||
|
<option value="" disabled>Select a role</option>
|
||||||
|
@for (r of roles(); track r.roleId) {
|
||||||
|
<option [value]="r.roleId">{{ r.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create User</button>
|
||||||
|
</form>
|
||||||
|
</app-modal-shell>
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
.admin-page {
|
||||||
|
padding: 24px 32px;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #EEF2FF;
|
||||||
|
color: #4338CA;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.active) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e5e0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #bbb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit'] {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
border-top: 1px solid #e5e5e0;
|
||||||
|
margin: 28px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-success {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { AdminService, ProgramDto, DistrictUserDto, RoleDto } from '../../../shared/services/admin.service';
|
||||||
|
import { ModalShell } from '../../components/modal-shell/modal-shell';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin',
|
||||||
|
imports: [FormsModule, ModalShell],
|
||||||
|
templateUrl: './admin.html',
|
||||||
|
styleUrl: './admin.scss',
|
||||||
|
})
|
||||||
|
export class Admin {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
private readonly adminService = inject(AdminService);
|
||||||
|
|
||||||
|
protected readonly activeTab = signal<'programs' | 'users'>('programs');
|
||||||
|
protected readonly programs = signal<ProgramDto[]>([]);
|
||||||
|
protected readonly users = signal<DistrictUserDto[]>([]);
|
||||||
|
protected readonly roles = signal<RoleDto[]>([]);
|
||||||
|
protected readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Program modal
|
||||||
|
protected readonly showProgramModal = signal(false);
|
||||||
|
protected readonly editingProgram = signal<ProgramDto | null>(null);
|
||||||
|
protected programName = '';
|
||||||
|
protected programDescription = '';
|
||||||
|
|
||||||
|
// User modal
|
||||||
|
protected readonly showUserModal = signal(false);
|
||||||
|
protected userName = '';
|
||||||
|
protected userEmail = '';
|
||||||
|
protected userPassword = '';
|
||||||
|
protected userProgramId = '';
|
||||||
|
protected userRoleId = '';
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
protected readonly backingUp = signal(false);
|
||||||
|
protected readonly backupSuccess = signal<string | null>(null);
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
onSwitchTab(tab: 'programs' | 'users') {
|
||||||
|
this.activeTab.set(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Programs ---
|
||||||
|
|
||||||
|
onAddProgram() {
|
||||||
|
this.editingProgram.set(null);
|
||||||
|
this.programName = '';
|
||||||
|
this.programDescription = '';
|
||||||
|
this.showProgramModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditProgram(program: ProgramDto) {
|
||||||
|
this.editingProgram.set(program);
|
||||||
|
this.programName = program.name;
|
||||||
|
this.programDescription = program.description || '';
|
||||||
|
this.showProgramModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSaveProgram() {
|
||||||
|
this.error.set(null);
|
||||||
|
const editing = this.editingProgram();
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
const result = await this.adminService.updateProgram(editing.programId, this.programName, this.programDescription);
|
||||||
|
if (!result.success) {
|
||||||
|
this.error.set(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await this.adminService.createProgram(this.programName, this.programDescription);
|
||||||
|
if (!result.success) {
|
||||||
|
this.error.set(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showProgramModal.set(false);
|
||||||
|
this.programs.set(await this.adminService.getPrograms());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
|
||||||
|
onAddUser() {
|
||||||
|
this.userName = '';
|
||||||
|
this.userEmail = '';
|
||||||
|
this.userPassword = '';
|
||||||
|
this.userProgramId = '';
|
||||||
|
this.userRoleId = '';
|
||||||
|
this.showUserModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSaveUser() {
|
||||||
|
this.error.set(null);
|
||||||
|
const result = await this.adminService.createUser(
|
||||||
|
this.userEmail, this.userName, this.userPassword,
|
||||||
|
this.userProgramId, this.userRoleId
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
this.error.set(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showUserModal.set(false);
|
||||||
|
this.users.set(await this.adminService.getUsers());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Backup ---
|
||||||
|
|
||||||
|
async onBackupDatabase() {
|
||||||
|
this.backingUp.set(true);
|
||||||
|
this.backupSuccess.set(null);
|
||||||
|
this.error.set(null);
|
||||||
|
try {
|
||||||
|
await this.adminService.backupDatabase();
|
||||||
|
this.backupSuccess.set('Backup downloaded successfully.');
|
||||||
|
} catch {
|
||||||
|
this.error.set('Database backup failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.backingUp.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
private async loadAll() {
|
||||||
|
this.programs.set(await this.adminService.getPrograms());
|
||||||
|
this.users.set(await this.adminService.getUsers());
|
||||||
|
this.roles.set(await this.adminService.getRoles());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,9 @@
|
|||||||
|
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<a class="nav-link" routerLink="/reports">Reports</a>
|
<a class="nav-link" routerLink="/reports">Reports</a>
|
||||||
|
@if (auth.user()?.role === 'district_admin' || auth.user()?.role === 'super_admin') {
|
||||||
|
<a class="nav-link" routerLink="/admin">Admin</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
{{ loading() ? 'Signing in...' : 'Sign in' }}
|
{{ loading() ? 'Signing in...' : 'Sign in' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<a class="link-button" routerLink="/register">Register your school district</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { Router } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { Auth } from '../../services/auth';
|
import { Auth } from '../../services/auth';
|
||||||
import { describeHttpError } from '../../classes/http-errors';
|
import { describeHttpError } from '../../classes/http-errors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, RouterLink],
|
||||||
templateUrl: './login.html',
|
templateUrl: './login.html',
|
||||||
styleUrl: './login.css',
|
styleUrl: './login.css',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
@if (success()) {
|
||||||
|
<div class="card">
|
||||||
|
<h2>Registration Complete</h2>
|
||||||
|
<p class="subtitle">Your district has been created. You can now log in.</p>
|
||||||
|
<a class="link-button" routerLink="/login">Go to Login</a>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="card">
|
||||||
|
<h2>Register Your School District</h2>
|
||||||
|
<p class="subtitle">Create your account and set up your school district.</p>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<p class="error">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form (ngSubmit)="onRegister()">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Your Details</legend>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" [(ngModel)]="name" name="name" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" [(ngModel)]="email" name="email" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" [(ngModel)]="password" name="password" required />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>District Details</legend>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
District Name
|
||||||
|
<input type="text" [(ngModel)]="districtName" name="districtName" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Contact Email (optional)
|
||||||
|
<input type="email" [(ngModel)]="districtContactEmail" name="districtContactEmail" />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Your First Program</legend>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Program Name
|
||||||
|
<input type="text" [(ngModel)]="programName" name="programName" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Description (optional)
|
||||||
|
<textarea [(ngModel)]="programDescription" name="programDescription" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit" [disabled]="loading()">
|
||||||
|
{{ loading() ? 'Creating...' : 'Create District & Account' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a class="link-button" routerLink="/login">Already have an account? Sign in</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { Api } from '../../services/api';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register',
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
templateUrl: './register.html',
|
||||||
|
styleUrl: './register.css',
|
||||||
|
})
|
||||||
|
export class Register {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
private readonly api = inject(Api);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
name = '';
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
districtName = '';
|
||||||
|
districtContactEmail = '';
|
||||||
|
programName = '';
|
||||||
|
programDescription = '';
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly error = signal<string | null>(null);
|
||||||
|
protected readonly success = signal(false);
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
onRegister() {
|
||||||
|
this.error.set(null);
|
||||||
|
this.loading.set(true);
|
||||||
|
|
||||||
|
this.api.register({
|
||||||
|
email: this.email,
|
||||||
|
password: this.password,
|
||||||
|
name: this.name,
|
||||||
|
districtName: this.districtName,
|
||||||
|
districtContactEmail: this.districtContactEmail || undefined,
|
||||||
|
programName: this.programName,
|
||||||
|
programDescription: this.programDescription || undefined,
|
||||||
|
}).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
if (res.success) {
|
||||||
|
this.success.set(true);
|
||||||
|
} else {
|
||||||
|
this.error.set(res.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set('An unexpected error occurred. Please try again.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,12 @@ export class Api {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-service registration — creates a new district + program + user
|
||||||
|
register(request: { email: string; password: string; name: string; districtName: string; districtContactEmail?: string; programName: string; programDescription?: string }): Observable<ResponseResult<object>> {
|
||||||
|
return this.http.post<ResponseResult<object>>(
|
||||||
|
`${this.base}/api/Auth/Register`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user