feat(admin): added backend for admin pages

This commit is contained in:
Fishandchips321 2026-03-05 17:19:24 +00:00
parent 54cfc05b88
commit 56ea7fb7f0
26 changed files with 662 additions and 42 deletions

View file

@ -15,7 +15,7 @@ public class AuthController : ControllerBase
_service = service;
}
[HttpPost]
[HttpPost("login")]
public async Task<IActionResult> Authenticate([FromForm] string username, [FromForm] string password)
{
try
@ -29,4 +29,123 @@ public class AuthController : ControllerBase
return Unauthorized();
}
}
[HttpGet]
public async Task<IActionResult> GetLogins()
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
var logins = await _service.GetLogins();
return Ok(logins);
}
[HttpGet("{username}")]
public async Task<IActionResult> GetLogin([FromRoute] string username)
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
try
{
var login = await _service.GetLogin(username);
return Ok(login);
}
catch (AuthNotFoundException)
{
return NotFound();
}
}
[HttpPost]
public async Task<IActionResult> CreateLogin([FromForm] string username, [FromForm] string password)
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
var newLogin = await _service.CreateLogin(username, password);
return Ok(newLogin);
}
[HttpPut]
public async Task<IActionResult> UpdateLogin([FromForm] string username, [FromForm] string password, [FromForm] string newPassword)
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
try
{
await _service.UpdateLoginOwnPassword(username, newPassword, password);
}
catch (AuthNotFoundException)
{
return BadRequest();
}
catch (LoginFailedException) //the old password is wrong
{
return Unauthorized();
}
return Ok();
}
[HttpDelete]
public async Task<IActionResult> DeleteLogin([FromForm] string username)
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
try
{
var deletedLogin = await _service.DeleteLogin(username);
return Ok(deletedLogin);
}
catch (AuthNotFoundException)
{
return NotFound();
}
}
private string? GetSessionToken()
{
return Request.Cookies["session"];
}
private async Task<bool> IsAuthenticated()
{
var sessionToken = GetSessionToken();
if (await _service.IsAuthenticated(sessionToken))
{
return true;
}
return false;
}
private async Task<bool> IsAdminAuthenticated()
{
var sessionToken = GetSessionToken();
if (await _service.IsAuthenticated(sessionToken) && await _service.IsAdmin(sessionToken))
{
return true;
}
return false;
}
}

View file

@ -1,3 +1,4 @@
using JellyGlass.Exceptions;
using JellyGlass.Services;
using Microsoft.AspNetCore.Mvc;
using System.Web;
@ -30,8 +31,15 @@ public class SearchController : ControllerBase
}
var decodedSearchTerm = HttpUtility.UrlDecode(searchTerm);
var results = await _service.Search(decodedSearchTerm, serverId);
try
{
var results = await _service.Search(decodedSearchTerm, serverId);
return Ok(results);
}
catch (ClientNotFoundException)
{
return NotFound();
}
}
}

View file

@ -1,3 +1,4 @@
using JellyGlass.Exceptions;
using JellyGlass.Services;
using Microsoft.AspNetCore.Mvc;
@ -18,12 +19,10 @@ public class ServersController : ControllerBase
_authService = authService;
}
[HttpGet]
public async Task<IActionResult> getServers()
[HttpGet("all")]
public async Task<IActionResult> GetServers()
{
var sessionToken = Request.Cookies["session"];
if (!await _authService.IsAuthenticated(sessionToken))
if (!await IsAuthenticated())
{
return Unauthorized();
}
@ -32,4 +31,98 @@ public class ServersController : ControllerBase
return Ok(servers);
}
[HttpGet]
public async Task<IActionResult> GetServer([FromQuery] int id)
{
try
{
var server = await _serverService.GetServerByID(id);
return Ok(server);
}
catch (ServerNotFoundException)
{
return NotFound();
}
}
[HttpPut]
public async Task<IActionResult> UpdateServer([FromBody] int id, string owner, string url, string apiToken)
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
try
{
var updatedServer = await _serverService.UpdateServer(id, owner, url, apiToken);
return Ok(updatedServer);
}
catch (ServerNotFoundException)
{
return NotFound();
}
}
[HttpPost]
public async Task<IActionResult> AddServer([FromBody] string owner, string url, string apiToken)
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
var newServer = await _serverService.CreateServer(owner, url, apiToken);
return Ok(newServer);
}
[HttpDelete]
public async Task<IActionResult> DeleteServer([FromBody] int id)
{
if (!await IsAdminAuthenticated())
{
return Forbid();
}
try
{
var deletedServer = await _serverService.DeleteServer(id);
return Ok(deletedServer);
}
catch (ServerNotFoundException)
{
return NotFound();
}
}
private string? GetSessionToken()
{
return Request.Cookies["session"];
}
private async Task<bool> IsAuthenticated()
{
var sessionToken = GetSessionToken();
if (await _authService.IsAuthenticated(sessionToken))
{
return true;
}
return false;
}
private async Task<bool> IsAdminAuthenticated()
{
var sessionToken = GetSessionToken();
if (await _authService.IsAuthenticated(sessionToken) && await _authService.IsAdmin(sessionToken))
{
return true;
}
return false;
}
}

View file

@ -7,10 +7,16 @@ public class AuthRepositoryException : System.Exception
public AuthRepositoryException(string message, System.Exception inner) : base(message, inner) { }
}
[System.Serializable]
public class AuthNotFoundException : AuthRepositoryException
{
public AuthNotFoundException() { }
public AuthNotFoundException(string message) : base(message) { }
public AuthNotFoundException(string message, System.Exception inner) : base(message, inner) { }
}
public class AuthAlreadyExistsException : AuthRepositoryException
{
public AuthAlreadyExistsException() { }
public AuthAlreadyExistsException(string message) : base(message) { }
public AuthAlreadyExistsException(string message, System.Exception inner) : base(message, inner) { }
}

View file

@ -0,0 +1,16 @@
namespace JellyGlass.Exceptions;
public class ClientServiceException : System.Exception
{
public ClientServiceException() { }
public ClientServiceException(string message) : base(message) { }
public ClientServiceException(string message, System.Exception inner) : base(message, inner) { }
}
[System.Serializable]
public class ClientNotFoundException : ClientServiceException
{
public ClientNotFoundException() { }
public ClientNotFoundException(string message) : base(message) { }
public ClientNotFoundException(string message, System.Exception inner) : base(message, inner) { }
}

View file

@ -0,0 +1,15 @@
namespace JellyGlass.Exceptions;
public class ServerRepositoryException : Exception
{
public ServerRepositoryException() { }
public ServerRepositoryException(string message) : base(message) { }
public ServerRepositoryException(string message, Exception inner) : base(message, inner) { }
}
public class ServerNotFoundException : ServerRepositoryException
{
public ServerNotFoundException() { }
public ServerNotFoundException(string message) : base(message) { }
public ServerNotFoundException(string message, Exception inner) : base(message, inner) { }
}

View file

@ -0,0 +1,92 @@
// <auto-generated />
using System;
using JellyGlass.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace JellyGlassBackend.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260305170928_admin")]
partial class admin
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("JellyGlass.Models.Server", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Servers");
});
modelBuilder.Entity("JellyGlass.Models.UserLogin", b =>
{
b.Property<string>("Username")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.HasKey("Username");
b.ToTable("Logins");
});
modelBuilder.Entity("JellyGlass.Models.UserSession", b =>
{
b.Property<string>("SessionToken")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresOn")
.HasColumnType("TEXT");
b.Property<string>("LoginUsername")
.HasColumnType("TEXT");
b.HasKey("SessionToken");
b.HasIndex("LoginUsername");
b.ToTable("Sessions");
});
modelBuilder.Entity("JellyGlass.Models.UserSession", b =>
{
b.HasOne("JellyGlass.Models.UserLogin", "Login")
.WithMany()
.HasForeignKey("LoginUsername");
b.Navigation("Login");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JellyGlassBackend.Migrations
{
/// <inheritdoc />
public partial class admin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Id",
table: "Servers",
type: "INTEGER",
nullable: false,
oldClrType: typeof(string),
oldType: "TEXT")
.Annotation("Sqlite:Autoincrement", true);
migrationBuilder.AddColumn<bool>(
name: "IsAdmin",
table: "Logins",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAdmin",
table: "Logins");
migrationBuilder.AlterColumn<string>(
name: "Id",
table: "Servers",
type: "TEXT",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER")
.OldAnnotation("Sqlite:Autoincrement", true);
}
}
}

View file

@ -19,8 +19,9 @@ namespace JellyGlassBackend.Migrations
modelBuilder.Entity("JellyGlass.Models.Server", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiToken")
.IsRequired()
@ -48,6 +49,9 @@ namespace JellyGlassBackend.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.HasKey("Username");
b.ToTable("Logins");

View file

@ -1,7 +0,0 @@
namespace JellyGlass.Models;
public class LoginDTO
{
public required string Username { get; set; }
public required string Password { get; set; }
}

View file

@ -1,9 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace JellyGlass.Models;
public class Server
{
public string Owner { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public string ApiToken { get; set; } = string.Empty;
}

View file

@ -13,6 +13,7 @@ public class ServerDTO
public string Owner { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
public int Id { get; set; }
public string JellyfinServerID { get; set; } = string.Empty;
public bool Errored { get; set; } = false;
}

View file

@ -7,4 +7,5 @@ public class UserLogin
[Key]
public required string Username { get; set; }
public required string HashedPassword { get; set; }
public required bool IsAdmin { get; set; } = false;
}

View file

@ -0,0 +1,13 @@
namespace JellyGlass.Models;
public class UserLoginDTO
{
public UserLoginDTO(UserLogin login)
{
Username = login.Username;
IsAdmin = login.IsAdmin;
}
public string Username { get; set; }
public bool IsAdmin { get; set; }
}

View file

@ -5,5 +5,8 @@ namespace JellyGlass.Repositories;
public interface IServerRepository
{
public Task<Server[]> GetServers();
public Task<Server> GetServerById(string id);
public Task<Server> GetServerById(int id);
public Task<Server> CreateServer(string owner, string url, string apiToken);
public Task<Server> UpdateServer(int id, string owner, string url, string apiToken);
public Task<Server> DeleteServer(int id);
}

View file

@ -5,4 +5,9 @@ namespace JellyGlass.Repositories;
public interface ILoginRepository
{
public Task<UserLogin> GetUserLogin(string username);
public Task<UserLogin[]> GetUserLogins();
public Task<UserLogin> CreateLogin(string username, string hashedPassword, bool isAdmin);
public Task<UserLogin> ChangeUserPassword(string username, string hashedPassword);
public Task<UserLogin> ChangeUserAdmin(string username, bool isAdmin);
public Task<UserLogin> DeleteLogin(string username);
}

View file

@ -24,4 +24,65 @@ public class LoginRepository : ILoginRepository
return login;
}
public async Task<UserLogin[]> GetUserLogins()
{
var logins = await _context.Logins.ToArrayAsync();
return logins;
}
public async Task<UserLogin> CreateLogin(string username, string hashedPassword, bool isAdmin)
{
if (_context.Logins.Where(l => l.Username == username).Any())
{
throw new AuthAlreadyExistsException(username);
}
var newLogin = new UserLogin()
{
Username = username,
HashedPassword = hashedPassword,
IsAdmin = isAdmin
};
await _context.Logins.AddAsync(newLogin);
await _context.SaveChangesAsync();
return newLogin;
}
public async Task<UserLogin> ChangeUserPassword(string username, string hashedPassword)
{
var login = await GetUserLogin(username);
login.HashedPassword = hashedPassword;
_context.Logins.Update(login);
await _context.SaveChangesAsync();
return login;
}
public async Task<UserLogin> ChangeUserAdmin(string username, bool isAdmin)
{
var login = await GetUserLogin(username);
login.IsAdmin = isAdmin;
_context.Logins.Update(login);
await _context.SaveChangesAsync();
return login;
}
public async Task<UserLogin> DeleteLogin(string username)
{
var login = await GetUserLogin(username);
_context.Logins.Remove(login);
await _context.SaveChangesAsync();
return login;
}
}

View file

@ -1,3 +1,4 @@
using JellyGlass.Exceptions;
using JellyGlass.Models;
using Microsoft.EntityFrameworkCore;
@ -19,10 +20,55 @@ public class ServerRepository : IServerRepository
return servers;
}
public async Task<Server> GetServerById(string id)
public async Task<Server> GetServerById(int id)
{
var server = await _context.Servers.FirstOrDefaultAsync(s => s.Id == id);
if (server == null)
{
throw new ServerNotFoundException();
}
return server;
}
public async Task<Server> CreateServer(string owner, string url, string apiToken)
{
var newServer = new Server()
{
ApiToken = apiToken,
Url = url,
Owner = owner
};
await _context.Servers.AddAsync(newServer);
await _context.SaveChangesAsync();
return newServer;
}
public async Task<Server> UpdateServer(int id, string owner, string url, string apiToken)
{
var server = await GetServerById(id);
server.Owner = owner;
server.Url = url;
server.ApiToken = apiToken;
_context.Servers.Update(server);
await _context.SaveChangesAsync();
return server;
}
public async Task<Server> DeleteServer(int id)
{
var server = await GetServerById(id);
_context.Servers.Remove(server);
await _context.SaveChangesAsync();
return server;
}
}

View file

@ -30,7 +30,7 @@ public class SessionRepository : ISessionRepository
public async Task<UserSession?> GetUserSession(string sessionToken)
{
var session = await _context.Sessions.FirstOrDefaultAsync(s => s.SessionToken == sessionToken);
var session = await _context.Sessions.Include(s => s.Login).FirstOrDefaultAsync(s => s.SessionToken == sessionToken);
return session;
}

View file

@ -49,19 +49,87 @@ public class AuthService : IAuthService
var session = await _sessionRepo.GetUserSession(sessionToken);
if (session == null)
if (session == null) //session doesn't exist
{
return false;
}
else if (session.ExpiresOn < DateTime.Now)
else if (session.ExpiresOn < DateTime.Now) //session has timed out
{
await _sessionRepo.DeleteSession(sessionToken);
return false;
}
else
else //session exists and hasn't timed out
{
await _sessionRepo.RefreshSessionExpiry(sessionToken);
return true;
}
}
public async Task<bool> IsAdmin(string sessionToken)
{
var session = await _sessionRepo.GetUserSession(sessionToken);
if (session == null)
{
throw new SessionNotFoundException();
}
return session.Login.IsAdmin;
}
public async Task<UserLoginDTO[]> GetLogins()
{
var logins = await _loginRepo.GetUserLogins();
var loginDTOs = new List<UserLoginDTO>();
foreach (var login in logins)
{
loginDTOs.Add(new UserLoginDTO(login));
}
return loginDTOs.ToArray();
}
public async Task<UserLoginDTO> GetLogin(string username)
{
var login = await _loginRepo.GetUserLogin(username);
return new UserLoginDTO(login);
}
public async Task<UserLoginDTO> CreateLogin(string username, string password)
{
var hashedPassword = BCrypt.Net.BCrypt.HashPassword(password);
var newLogin = await _loginRepo.CreateLogin(username, hashedPassword, false);
return new UserLoginDTO(newLogin);
}
public async Task UpdateLoginOwnPassword(string username, string newPassword, string oldPassword)
{
var login = await _loginRepo.GetUserLogin(username);
if (!BCrypt.Net.BCrypt.Verify(oldPassword, login.HashedPassword))
{
throw new LoginFailedException();
}
await UpdateLoginPassword(username, newPassword);
}
public async Task UpdateLoginPassword(string username, string newPassword)
{
var hashedNewPassword = BCrypt.Net.BCrypt.HashPassword(newPassword);
await _loginRepo.ChangeUserPassword(username, hashedNewPassword);
}
public async Task<UserLoginDTO> DeleteLogin(string username)
{
var deletedLogin = await _loginRepo.DeleteLogin(username);
return new UserLoginDTO(deletedLogin);
}
}

View file

@ -6,4 +6,11 @@ public interface IAuthService
{
public Task<UserSessionDTO> AuthenticateUser(string username, string password);
public Task<bool> IsAuthenticated(string? sessionToken);
public Task<bool> IsAdmin(string sessionToken);
public Task<UserLoginDTO[]> GetLogins();
public Task<UserLoginDTO> GetLogin(string username);
public Task<UserLoginDTO> CreateLogin(string username, string password);
public Task UpdateLoginOwnPassword(string username, string newPassword, string oldPassword);
public Task UpdateLoginPassword(string username, string newPassword);
public Task<UserLoginDTO> DeleteLogin(string username);
}

View file

@ -7,4 +7,8 @@ namespace JellyGlass.Services;
public interface IServerService
{
public Task<ServerDTO[]> GetServers();
public Task<ServerDTO> GetServerByID(int id);
public Task<ServerDTO> CreateServer(string owner, string url, string apiToken);
public Task<ServerDTO> UpdateServer(int id, string owner, string url, string apiToken);
public Task<ServerDTO> DeleteServer(int id);
}

View file

@ -28,11 +28,9 @@ public class ServerService : IServerService
{
_logger.LogInformation($"ID for server {client.InstanceUrl} is {client.ID}");
var dto = new ServerDTO();
var server = servers.First(s => s.Url == client.InstanceUrl);
dto.Id = client.ID;
dto.Url = client.InstanceUrl;
dto.Owner = server.Owner;
var dto = new ServerDTO(server);
dto.JellyfinServerID = client.ID;
dtos.Add(dto);
}
@ -51,17 +49,31 @@ public class ServerService : IServerService
return dtos.ToArray();
}
public async Task<ServerDTO[]> GetServers2()
public async Task<ServerDTO> GetServerByID(int id)
{
var servers = await _repository.GetServers();
var server = await _repository.GetServerById(id);
var dtos = new List<ServerDTO>();
foreach (var s in servers)
{
dtos.Add(new ServerDTO(s));
return new ServerDTO(server);
}
return dtos.ToArray();
public async Task<ServerDTO> CreateServer(string owner, string url, string apiToken)
{
var newServer = await _repository.CreateServer(owner, url, apiToken);
return new ServerDTO(newServer);
}
public async Task<ServerDTO> UpdateServer(int id, string owner, string url, string apiToken)
{
var updatedServer = await _repository.UpdateServer(id, owner, url, apiToken);
return new ServerDTO(updatedServer);
}
public async Task<ServerDTO> DeleteServer(int id)
{
var deletedServer = await _repository.DeleteServer(id);
return new ServerDTO(deletedServer);
}
}

View file

@ -13,7 +13,7 @@ const ServerSearch = ({ searchTerm, server }: ServerSearchProps) => {
const [searchResults, setSearchResults] = useState<Array<SearchResult | undefined>>();
useEffect(() => {
search(searchTerm, server.id).then(results => {
search(searchTerm, server.jellyfinServerID).then(results => {
setSearchResults(results);
}).catch(err => {
setSearchResults([]);

View file

@ -7,7 +7,7 @@ export interface Session {
}
export const logIn = async (username: string, password: string) => {
const response = await axios.post<Session>(`${apiUrl}/auth`, `username=${encodeURI(username)}&password=${encodeURI(password)}`);
const response = await axios.post<Session>(`${apiUrl}/auth/login`, `username=${encodeURI(username)}&password=${encodeURI(password)}`);
return response.data;
}

View file

@ -3,7 +3,8 @@ import { apiUrl } from "./api";
export interface Server {
name?: string;
id: string;
id: number;
jellyfinServerID: string;
errored: boolean;
owner: string;
url: string;
@ -11,7 +12,7 @@ export interface Server {
export const getServerList = async (): Promise<Array<Server>> => {
console.log("fetching server list");
const response = await axios.get<Array<Server>>(`${apiUrl}/servers`, { withCredentials: true });
const response = await axios.get<Array<Server>>(`${apiUrl}/servers/all`, { withCredentials: true });
console.log(response);