diff --git a/backend/src/Controllers/AuthController.cs b/backend/src/Controllers/AuthController.cs new file mode 100644 index 0000000..9bdf6bc --- /dev/null +++ b/backend/src/Controllers/AuthController.cs @@ -0,0 +1,32 @@ +using JellyGlass.Exceptions; +using JellyGlass.Services; +using Microsoft.AspNetCore.Mvc; + +namespace JellyGlass.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private IAuthService _service; + + public AuthController(IAuthService service) + { + _service = service; + } + + [HttpPost] + public async Task Authenticate([FromForm] string username, [FromForm] string password) + { + try + { + var session = await _service.AuthenticateUser(username, password); + + return Ok(session); + } + catch (LoginFailedException) + { + return Unauthorized(); + } + } +} \ No newline at end of file diff --git a/backend/src/Controllers/LibraryController.cs b/backend/src/Controllers/LibraryController.cs deleted file mode 100644 index f3fc142..0000000 --- a/backend/src/Controllers/LibraryController.cs +++ /dev/null @@ -1,51 +0,0 @@ -using JellyGlass.Models; -using JellyGlass.Services; -using Microsoft.AspNetCore.Mvc; - -namespace JellyGlass.Controllers; - -[ApiController] -[Route("/api/[controller]")] -public class LibraryController : ControllerBase -{ - private ILogger _logger; - private ILibraryService _service; - - public LibraryController(ILogger logger, ILibraryService service) - { - _logger = logger; - _service = service; - } - - [HttpGet()] - public async Task GetLibrares() - { - var libraries = await _service.GetLibraries(); - - return Ok(libraries); - } - - [HttpGet("{libraryName}")] - public async Task GetLibraryItems([FromRoute] string libraryName) - { - throw new NotImplementedException(); - } - - // [HttpGet("{libraryName}/Item/{itemName}")] - // public async Task GetLibraryItem([FromRoute] string libraryName, string itemName) - // { - - // } - - // [HttpGet("TvShows/{seriesName}")] - // public async Task GetSeasonsForTvSeries([FromRoute] string seriesName) - // { - - // } - - // [HttpGet("TvShows/{seriesName}/Season/{seasonName}")] - // public async Task GetEpisodesForTvSeriesSeason([FromRoute] string seriesName, string seasonName) - // { - - // } -} \ No newline at end of file diff --git a/backend/src/Controllers/MoviesController.cs b/backend/src/Controllers/MoviesController.cs deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/Controllers/SearchController.cs b/backend/src/Controllers/SearchController.cs index 3dc3075..dbcd1cc 100644 --- a/backend/src/Controllers/SearchController.cs +++ b/backend/src/Controllers/SearchController.cs @@ -10,16 +10,25 @@ public class SearchController : ControllerBase { private ILogger _logger; private ISearchService _service; + private IAuthService _auth; - public SearchController(ILogger logger, ISearchService service) + public SearchController(ILogger logger, ISearchService service, IAuthService auth) { _logger = logger; _service = service; + _auth = auth; } [HttpGet] public async Task handleSearch([FromQuery] string searchTerm, string serverId) { + var sessionToken = Request.Cookies["session"]; + + if (!await _auth.IsAuthenticated(sessionToken)) + { + return Unauthorized(); + } + var decodedSearchTerm = HttpUtility.UrlDecode(searchTerm); var results = await _service.Search(decodedSearchTerm, serverId); diff --git a/backend/src/Controllers/ServersController.cs b/backend/src/Controllers/ServersController.cs index 92e7ae3..52f1baa 100644 --- a/backend/src/Controllers/ServersController.cs +++ b/backend/src/Controllers/ServersController.cs @@ -8,18 +8,27 @@ namespace JellyGlass.Controllers; public class ServersController : ControllerBase { private ILogger _logger; - private IServerService _service; + private IServerService _serverService; + private IAuthService _authService; - public ServersController(ILogger logger, IServerService service) + public ServersController(ILogger logger, IServerService serverService, IAuthService authService) { _logger = logger; - _service = service; + _serverService = serverService; + _authService = authService; } [HttpGet] public async Task getServers() { - var servers = await _service.GetServers(); + var sessionToken = Request.Cookies["session"]; + + if (!await _authService.IsAuthenticated(sessionToken)) + { + return Unauthorized(); + } + + var servers = await _serverService.GetServers(); return Ok(servers); } diff --git a/backend/src/Controllers/TvShowsController.cs b/backend/src/Controllers/TvShowsController.cs deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/Exceptions/AuthRepositoryExceptions.cs b/backend/src/Exceptions/AuthRepositoryExceptions.cs new file mode 100644 index 0000000..aebf688 --- /dev/null +++ b/backend/src/Exceptions/AuthRepositoryExceptions.cs @@ -0,0 +1,16 @@ +namespace JellyGlass.Exceptions; + +public class AuthRepositoryException : System.Exception +{ + public AuthRepositoryException() { } + public AuthRepositoryException(string message) : base(message) { } + 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) { } +} \ No newline at end of file diff --git a/backend/src/Exceptions/AuthServiceExceptions.cs b/backend/src/Exceptions/AuthServiceExceptions.cs new file mode 100644 index 0000000..29c2a58 --- /dev/null +++ b/backend/src/Exceptions/AuthServiceExceptions.cs @@ -0,0 +1,16 @@ +namespace JellyGlass.Exceptions; + +public class AuthServiceException : Exception +{ + public AuthServiceException() { } + public AuthServiceException(string message) : base(message) { } + public AuthServiceException(string message, Exception inner) : base(message, inner) { } +} + +[System.Serializable] +public class LoginFailedException : AuthServiceException +{ + public LoginFailedException() { } + public LoginFailedException(string message) : base(message) { } + public LoginFailedException(string message, System.Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/backend/src/Exceptions/SessionRepositoryExceptions.cs b/backend/src/Exceptions/SessionRepositoryExceptions.cs new file mode 100644 index 0000000..4ce898c --- /dev/null +++ b/backend/src/Exceptions/SessionRepositoryExceptions.cs @@ -0,0 +1,15 @@ +namespace JellyGlass.Exceptions; + +public class SessionRepositoryException : Exception +{ + public SessionRepositoryException() { } + public SessionRepositoryException(string message) : base(message) { } + public SessionRepositoryException(string message, Exception inner) : base(message, inner) { } +} + +public class SessionNotFoundException : SessionRepositoryException +{ + public SessionNotFoundException() : base() { } + public SessionNotFoundException(string message) : base(message) { } + public SessionNotFoundException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/backend/src/JellyGlass-Backend.csproj b/backend/src/JellyGlass-Backend.csproj index 2bc8cc1..2c774e3 100644 --- a/backend/src/JellyGlass-Backend.csproj +++ b/backend/src/JellyGlass-Backend.csproj @@ -1,18 +1,19 @@ - - net9.0 - enable - enable - + + net9.0 + enable + enable + - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + - + \ No newline at end of file diff --git a/backend/src/Migrations/20260303190433_auth.Designer.cs b/backend/src/Migrations/20260303190433_auth.Designer.cs new file mode 100644 index 0000000..d42ac59 --- /dev/null +++ b/backend/src/Migrations/20260303190433_auth.Designer.cs @@ -0,0 +1,88 @@ +// +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("20260303190433_auth")] + partial class auth + { + /// + 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("Id") + .HasColumnType("TEXT"); + + b.Property("ApiToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Servers"); + }); + + modelBuilder.Entity("JellyGlass.Models.UserLogin", b => + { + b.Property("Username") + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Username"); + + b.ToTable("Logins"); + }); + + modelBuilder.Entity("JellyGlass.Models.UserSession", b => + { + b.Property("SessionToken") + .HasColumnType("TEXT"); + + b.Property("ExpiresOn") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/backend/src/Migrations/20260303190433_auth.cs b/backend/src/Migrations/20260303190433_auth.cs new file mode 100644 index 0000000..c054f4a --- /dev/null +++ b/backend/src/Migrations/20260303190433_auth.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JellyGlassBackend.Migrations +{ + /// + public partial class auth : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Logins", + columns: table => new + { + Username = table.Column(type: "TEXT", nullable: false), + HashedPassword = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Logins", x => x.Username); + }); + + migrationBuilder.CreateTable( + name: "Sessions", + columns: table => new + { + SessionToken = table.Column(type: "TEXT", nullable: false), + LoginUsername = table.Column(type: "TEXT", nullable: true), + ExpiresOn = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sessions", x => x.SessionToken); + table.ForeignKey( + name: "FK_Sessions_Logins_LoginUsername", + column: x => x.LoginUsername, + principalTable: "Logins", + principalColumn: "Username"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Sessions_LoginUsername", + table: "Sessions", + column: "LoginUsername"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Sessions"); + + migrationBuilder.DropTable( + name: "Logins"); + } + } +} diff --git a/backend/src/Migrations/DatabaseContextModelSnapshot.cs b/backend/src/Migrations/DatabaseContextModelSnapshot.cs index 0594134..6db48d8 100644 --- a/backend/src/Migrations/DatabaseContextModelSnapshot.cs +++ b/backend/src/Migrations/DatabaseContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using JellyGlass.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -37,6 +38,47 @@ namespace JellyGlassBackend.Migrations b.ToTable("Servers"); }); + + modelBuilder.Entity("JellyGlass.Models.UserLogin", b => + { + b.Property("Username") + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Username"); + + b.ToTable("Logins"); + }); + + modelBuilder.Entity("JellyGlass.Models.UserSession", b => + { + b.Property("SessionToken") + .HasColumnType("TEXT"); + + b.Property("ExpiresOn") + .HasColumnType("TEXT"); + + b.Property("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 } } diff --git a/backend/src/Models/LoginDTO.cs b/backend/src/Models/LoginDTO.cs new file mode 100644 index 0000000..b38d465 --- /dev/null +++ b/backend/src/Models/LoginDTO.cs @@ -0,0 +1,7 @@ +namespace JellyGlass.Models; + +public class LoginDTO +{ + public required string Username { get; set; } + public required string Password { get; set; } +} \ No newline at end of file diff --git a/backend/src/Models/UserLogin.cs b/backend/src/Models/UserLogin.cs new file mode 100644 index 0000000..10179f9 --- /dev/null +++ b/backend/src/Models/UserLogin.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace JellyGlass.Models; + +public class UserLogin +{ + [Key] + public required string Username { get; set; } + public required string HashedPassword { get; set; } +} \ No newline at end of file diff --git a/backend/src/Models/UserSession.cs b/backend/src/Models/UserSession.cs new file mode 100644 index 0000000..9e520ae --- /dev/null +++ b/backend/src/Models/UserSession.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace JellyGlass.Models; + +public class UserSession +{ + public required UserLogin Login { get; set; } + [Key] + public required string SessionToken { get; set; } + public required DateTime ExpiresOn { get; set; } +} \ No newline at end of file diff --git a/backend/src/Models/UserSessionDTO.cs b/backend/src/Models/UserSessionDTO.cs new file mode 100644 index 0000000..2b29943 --- /dev/null +++ b/backend/src/Models/UserSessionDTO.cs @@ -0,0 +1,13 @@ +namespace JellyGlass.Models; + +public class UserSessionDTO +{ + public UserSessionDTO(UserSession session) + { + SessionToken = session.SessionToken; + ExpiresOn = session.ExpiresOn; + } + + public string SessionToken { get; set; } + public DateTime ExpiresOn { get; set; } +} \ No newline at end of file diff --git a/backend/src/Program.cs b/backend/src/Program.cs index b780808..d8730e2 100644 --- a/backend/src/Program.cs +++ b/backend/src/Program.cs @@ -24,11 +24,15 @@ else builder.Services.AddSqlite(dbConnectionString); -builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddTransient(); var app = builder.Build(); diff --git a/backend/src/Repositories/DatabaseContext.cs b/backend/src/Repositories/DatabaseContext.cs index c6e3ae1..b782564 100644 --- a/backend/src/Repositories/DatabaseContext.cs +++ b/backend/src/Repositories/DatabaseContext.cs @@ -5,11 +5,13 @@ namespace JellyGlass.Repositories; public class DatabaseContext : DbContext { - public DatabaseContext(DbContextOptions options) + public DatabaseContext(DbContextOptions options) : base(options) { } public DbSet Servers { get; set; } + public DbSet Logins { get; set; } + public DbSet Sessions { get; set; } } \ No newline at end of file diff --git a/backend/src/Repositories/ISessionRepository.cs b/backend/src/Repositories/ISessionRepository.cs new file mode 100644 index 0000000..89fa04a --- /dev/null +++ b/backend/src/Repositories/ISessionRepository.cs @@ -0,0 +1,11 @@ +using JellyGlass.Models; + +namespace JellyGlass.Repositories; + +public interface ISessionRepository +{ + public Task CreateUserSession(UserLogin user); + public Task GetUserSession(string sessionToken); + public Task RefreshSessionExpiry(string sessionToken); + public Task DeleteSession(string sessionToken); +} \ No newline at end of file diff --git a/backend/src/Repositories/IloginRepository.cs b/backend/src/Repositories/IloginRepository.cs new file mode 100644 index 0000000..37997ba --- /dev/null +++ b/backend/src/Repositories/IloginRepository.cs @@ -0,0 +1,8 @@ +using JellyGlass.Models; + +namespace JellyGlass.Repositories; + +public interface ILoginRepository +{ + public Task GetUserLogin(string username); +} \ No newline at end of file diff --git a/backend/src/Repositories/LoginRepository.cs b/backend/src/Repositories/LoginRepository.cs new file mode 100644 index 0000000..accfce7 --- /dev/null +++ b/backend/src/Repositories/LoginRepository.cs @@ -0,0 +1,27 @@ +using JellyGlass.Exceptions; +using JellyGlass.Models; +using Microsoft.EntityFrameworkCore; + +namespace JellyGlass.Repositories; + +public class LoginRepository : ILoginRepository +{ + private DatabaseContext _context; + + public LoginRepository(DatabaseContext context) + { + _context = context; + } + + public async Task GetUserLogin(string username) + { + var login = await _context.Logins.FirstOrDefaultAsync(l => l.Username == username); + + if (login == null) + { + throw new AuthNotFoundException($"Login {username} doesn't exist"); + } + + return login; + } +} \ No newline at end of file diff --git a/backend/src/Repositories/SessionRepository.cs b/backend/src/Repositories/SessionRepository.cs new file mode 100644 index 0000000..bec189d --- /dev/null +++ b/backend/src/Repositories/SessionRepository.cs @@ -0,0 +1,68 @@ +using JellyGlass.Exceptions; +using JellyGlass.Models; +using Microsoft.EntityFrameworkCore; + +namespace JellyGlass.Repositories; + +public class SessionRepository : ISessionRepository +{ + private DatabaseContext _context; + + public SessionRepository(DatabaseContext context) + { + _context = context; + } + + public async Task CreateUserSession(UserLogin user) + { + var newSession = new UserSession() + { + Login = user, + SessionToken = Guid.NewGuid().ToString(), + ExpiresOn = GenerateExpiryDate() + }; + + await _context.Sessions.AddAsync(newSession); + await _context.SaveChangesAsync(); + + return newSession; + } + + public async Task GetUserSession(string sessionToken) + { + var session = await _context.Sessions.FirstOrDefaultAsync(s => s.SessionToken == sessionToken); + + return session; + } + + public async Task RefreshSessionExpiry(string sessionToken) + { + var session = await GetUserSession(sessionToken); + + if (session == null) + { + throw new SessionNotFoundException($"Couldn't find session {sessionToken} while trying ti refresh token"); + } + + session.ExpiresOn = GenerateExpiryDate(); + + _context.Sessions.Update(session); + await _context.SaveChangesAsync(); + } + + public async Task DeleteSession(string sessionToken) + { + var session = await GetUserSession(sessionToken); + + if (session != null) + { + _context.Sessions.Remove(session); + await _context.SaveChangesAsync(); + } + } + + private static DateTime GenerateExpiryDate() + { + return DateTime.Now + TimeSpan.FromDays(30); //TODO: Make this configurable + } +} \ No newline at end of file diff --git a/backend/src/Services/AuthService.cs b/backend/src/Services/AuthService.cs new file mode 100644 index 0000000..7b98090 --- /dev/null +++ b/backend/src/Services/AuthService.cs @@ -0,0 +1,67 @@ +using JellyGlass.Exceptions; +using JellyGlass.Models; +using JellyGlass.Repositories; + +namespace JellyGlass.Services; + +public class AuthService : IAuthService +{ + private ILoginRepository _loginRepo; + private ISessionRepository _sessionRepo; + + public AuthService(ILoginRepository loginRepo, ISessionRepository sessionRepo) + { + _loginRepo = loginRepo; + _sessionRepo = sessionRepo; + } + + public async Task AuthenticateUser(string username, string password) + { + UserLogin login; + + try + { + login = await _loginRepo.GetUserLogin(username); + } + catch (AuthNotFoundException) + { + throw new LoginFailedException(); + } + + + if (BCrypt.Net.BCrypt.Verify(password, login.HashedPassword)) + { + var session = await _sessionRepo.CreateUserSession(login); + return new UserSessionDTO(session); + } + else + { + throw new LoginFailedException(); + } + } + + public async Task IsAuthenticated(string? sessionToken) + { + if (sessionToken == null) + { + return false; + } + + var session = await _sessionRepo.GetUserSession(sessionToken); + + if (session == null) + { + return false; + } + else if (session.ExpiresOn < DateTime.Now) + { + await _sessionRepo.DeleteSession(sessionToken); + return false; + } + else + { + await _sessionRepo.RefreshSessionExpiry(sessionToken); + return true; + } + } +} \ No newline at end of file diff --git a/backend/src/Services/IAuthService.cs b/backend/src/Services/IAuthService.cs new file mode 100644 index 0000000..c69b5b2 --- /dev/null +++ b/backend/src/Services/IAuthService.cs @@ -0,0 +1,9 @@ +using JellyGlass.Models; + +namespace JellyGlass.Services; + +public interface IAuthService +{ + public Task AuthenticateUser(string username, string password); + public Task IsAuthenticated(string? sessionToken); +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4f87763..919614e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.13.5", "bootstrap": "^5.3.8", + "js-cookie": "^3.0.5", "react": "^19.2.0", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.0", @@ -19,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/js-cookie": "^3.0.6", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -1819,6 +1821,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3285,6 +3294,15 @@ "dev": true, "license": "ISC" }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ad349f5..6ac24c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "axios": "^1.13.5", "bootstrap": "^5.3.8", + "js-cookie": "^3.0.5", "react": "^19.2.0", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.0", @@ -21,6 +22,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/js-cookie": "^3.0.6", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/Components/Navbar/Navbar.tsx b/frontend/src/Components/Navbar/Navbar.tsx index 8ac7ff2..578bd6f 100644 --- a/frontend/src/Components/Navbar/Navbar.tsx +++ b/frontend/src/Components/Navbar/Navbar.tsx @@ -1,12 +1,19 @@ -import { Navbar as BsNavbar, Container } from "react-bootstrap"; +import { Navbar as BsNavbar, Button, Container } from "react-bootstrap"; // import styles from "./Navbar.module.scss"; import Searchbar from "../Searchbar/Searchbar"; import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; +import Cookies from "js-cookie"; const Navbar = () => { const [searchText, setSearchText] = useState(""); const navigate = useNavigate(); + const session = Cookies.get("session"); + + function onLogout() { + Cookies.remove("session"); + navigate("/login"); + } function onSearch() { navigate(`/search?search=${searchText}`); @@ -22,9 +29,10 @@ const Navbar = () => { - + + {session && } ) diff --git a/frontend/src/Components/Searchbar/Searchbar.module.scss b/frontend/src/Components/Searchbar/Searchbar.module.scss index 61b2f50..eae788b 100644 --- a/frontend/src/Components/Searchbar/Searchbar.module.scss +++ b/frontend/src/Components/Searchbar/Searchbar.module.scss @@ -2,4 +2,10 @@ border-radius: 10px; padding: 5px; width: 100%; +} + +.searchbarDisabled { + @extend .searchbar; + background-color: lightgray; + cursor: not-allowed; } \ No newline at end of file diff --git a/frontend/src/Components/Searchbar/Searchbar.tsx b/frontend/src/Components/Searchbar/Searchbar.tsx index 6899411..01d876b 100644 --- a/frontend/src/Components/Searchbar/Searchbar.tsx +++ b/frontend/src/Components/Searchbar/Searchbar.tsx @@ -5,9 +5,10 @@ interface SearchbarProps { text: string, setText: (text: string) => void; onSearch?: () => void; + enabled?: boolean; } -const Searchbar = ({ text, setText, onSearch }: SearchbarProps) => { +const Searchbar = ({ text, setText, onSearch, enabled = true }: SearchbarProps) => { function onKeyPressed(event: React.KeyboardEvent) { if (onSearch === undefined) { @@ -20,7 +21,7 @@ const Searchbar = ({ text, setText, onSearch }: SearchbarProps) => { } return ( - setText(e.target.value)} onKeyUp={onKeyPressed} /> + setText(e.target.value)} onKeyUp={onKeyPressed} disabled={!enabled} /> ) } diff --git a/frontend/src/Components/ServerList/ServerList.tsx b/frontend/src/Components/ServerList/ServerList.tsx index d9801d6..49b8fca 100644 --- a/frontend/src/Components/ServerList/ServerList.tsx +++ b/frontend/src/Components/ServerList/ServerList.tsx @@ -11,6 +11,9 @@ const ServerList = () => { setServers(serverList); }).catch(err => { setServers([]); + if (err.response && err.response.status === 401) { + return; + } alert(err); }) }, []); diff --git a/frontend/src/Lib/Auth.ts b/frontend/src/Lib/Auth.ts new file mode 100644 index 0000000..d9691f5 --- /dev/null +++ b/frontend/src/Lib/Auth.ts @@ -0,0 +1,13 @@ +import axios from "axios" +import { apiUrl } from "./api" + +export interface Session { + sessionToken: string; + expiresOn: string; +} + +export const logIn = async (username: string, password: string) => { + const response = await axios.post(`${apiUrl}/auth`, `username=${encodeURI(username)}&password=${encodeURI(password)}`); + + return response.data; +} \ No newline at end of file diff --git a/frontend/src/Lib/Search.ts b/frontend/src/Lib/Search.ts index a482286..aa058ca 100644 --- a/frontend/src/Lib/Search.ts +++ b/frontend/src/Lib/Search.ts @@ -11,7 +11,7 @@ export interface SearchResult { } export const search = async (searchTerm: string, serverId: string): Promise> => { - const response = await axios.get>(encodeURI(`${apiUrl}/search?searchTerm=${searchTerm}&serverId=${serverId}`)); + const response = await axios.get>(encodeURI(`${apiUrl}/search?searchTerm=${searchTerm}&serverId=${serverId}`), { withCredentials: true }); return response.data; } diff --git a/frontend/src/Lib/Servers.ts b/frontend/src/Lib/Servers.ts index 1f728c0..11267d0 100644 --- a/frontend/src/Lib/Servers.ts +++ b/frontend/src/Lib/Servers.ts @@ -11,7 +11,7 @@ export interface Server { export const getServerList = async (): Promise> => { console.log("fetching server list"); - const response = await axios.get>(`${apiUrl}/servers`); + const response = await axios.get>(`${apiUrl}/servers`, { withCredentials: true }); console.log(response); diff --git a/frontend/src/Pages/Login/Login.module.scss b/frontend/src/Pages/Login/Login.module.scss new file mode 100644 index 0000000..59e92c3 --- /dev/null +++ b/frontend/src/Pages/Login/Login.module.scss @@ -0,0 +1,9 @@ +.form { + margin: auto; + margin-top: 50px; + max-width: 400px; +} + +.formButton { + margin-top: 10px; +} \ No newline at end of file diff --git a/frontend/src/Pages/Login/Login.tsx b/frontend/src/Pages/Login/Login.tsx new file mode 100644 index 0000000..b17cc26 --- /dev/null +++ b/frontend/src/Pages/Login/Login.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from "react"; +import { Button, Form, FormGroup } from "react-bootstrap"; +import { logIn } from "../../Lib/Auth"; +import Cookies from "js-cookie"; +import { useNavigate } from "react-router-dom"; +import styles from "./Login.module.scss"; + + +const Login = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const navigate = useNavigate(); + + useEffect(() => { + if (Cookies.get("session") !== undefined) { + navigate("/"); + } + }, [navigate]); + + function onSubmit() { + logIn(username, password).then(sessionToken => { + Cookies.set("session", sessionToken.sessionToken, { expires: Date.parse(sessionToken.expiresOn) }); + navigate("/"); + }).catch(err => { + alert(err); + }) + } + + function onFormKeypress(e: React.KeyboardEvent) { + if (e.key === "Enter") { + onSubmit(); + } + } + + return ( +
+
+

Login

+ + Username + setUsername(e.target.value)} /> + + + Password + setPassword(e.target.value)} /> + + +
+
+ ); +} + +export default Login; \ No newline at end of file diff --git a/frontend/src/Pages/Search/Search.tsx b/frontend/src/Pages/Search/Search.tsx index cafef8d..33d8c07 100644 --- a/frontend/src/Pages/Search/Search.tsx +++ b/frontend/src/Pages/Search/Search.tsx @@ -3,35 +3,51 @@ import { getServerList, type Server } from "../../Lib/Servers"; import ServerSearch from "../../Components/ServerSearch/ServerSearch"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Spinner } from "react-bootstrap"; +import Cookies from "js-cookie"; const Search = () => { const [searchParams] = useSearchParams(); const [servers, setServers] = useState>([]); const navigate = useNavigate(); + const sessionCookie = Cookies.get("session"); const searchTerm = searchParams.get("search") || ""; useEffect(() => { + if (!sessionCookie) { + navigate("/login"); + return; + } + if (searchTerm === "") { alert(`Error search term missing: ${searchTerm}`); navigate("/"); } - if (servers.length > 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setServers([]); - } - getServerList().then(servers => { if (servers.length === 0) { alert("No servers found"); } - setServers(servers); + + const workingServers: Array = []; + + servers.forEach(s => { + if (!s.errored) { + workingServers.push(s); + } + }) + + if (workingServers.length === 0) { + alert("No working servers"); + navigate("/"); + } + + setServers(workingServers); }).catch(e => { alert(e); }); - }, [searchTerm]); + }, [searchTerm, navigate]); return ( <> diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 5c89377..91c6d9b 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,6 +1,18 @@ -import ServerList from "./Components/ServerList/ServerList" +import { useEffect } from "react"; +import ServerList from "./Components/ServerList/ServerList"; +import Cookies from "js-cookie"; +import { useNavigate } from "react-router-dom"; const Index = () => { + const navigate = useNavigate(); + const sessionCookie = Cookies.get("session"); + + useEffect(() => { + if (!sessionCookie) { + navigate("/login"); + } + }, [navigate, sessionCookie]); + return (

Available Servers

diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f2aaa42..e39a684 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import Index from './index.tsx' import { BrowserRouter, Route, Routes } from 'react-router-dom' import Navbar from './Components/Navbar/Navbar.tsx' import Search from './Pages/Search/Search.tsx'; +import Login from './Pages/Login/Login.tsx'; createRoot(document.getElementById('root')!).render( @@ -13,6 +14,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> + } /> ,