feat(auth): Added authentication

This commit is contained in:
Fishandchips321 2026-03-05 12:34:55 +00:00
parent d85d4334f8
commit 5e100c75ed
39 changed files with 704 additions and 86 deletions

View file

@ -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<IActionResult> Authenticate([FromForm] string username, [FromForm] string password)
{
try
{
var session = await _service.AuthenticateUser(username, password);
return Ok(session);
}
catch (LoginFailedException)
{
return Unauthorized();
}
}
}

View file

@ -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<LibraryController> _logger;
private ILibraryService _service;
public LibraryController(ILogger<LibraryController> logger, ILibraryService service)
{
_logger = logger;
_service = service;
}
[HttpGet()]
public async Task<IActionResult> GetLibrares()
{
var libraries = await _service.GetLibraries();
return Ok(libraries);
}
[HttpGet("{libraryName}")]
public async Task<IActionResult> GetLibraryItems([FromRoute] string libraryName)
{
throw new NotImplementedException();
}
// [HttpGet("{libraryName}/Item/{itemName}")]
// public async Task<IActionResult> GetLibraryItem([FromRoute] string libraryName, string itemName)
// {
// }
// [HttpGet("TvShows/{seriesName}")]
// public async Task<IActionResult> GetSeasonsForTvSeries([FromRoute] string seriesName)
// {
// }
// [HttpGet("TvShows/{seriesName}/Season/{seasonName}")]
// public async Task<IActionResult> GetEpisodesForTvSeriesSeason([FromRoute] string seriesName, string seasonName)
// {
// }
}

View file

@ -10,16 +10,25 @@ public class SearchController : ControllerBase
{ {
private ILogger<SearchController> _logger; private ILogger<SearchController> _logger;
private ISearchService _service; private ISearchService _service;
private IAuthService _auth;
public SearchController(ILogger<SearchController> logger, ISearchService service) public SearchController(ILogger<SearchController> logger, ISearchService service, IAuthService auth)
{ {
_logger = logger; _logger = logger;
_service = service; _service = service;
_auth = auth;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> handleSearch([FromQuery] string searchTerm, string serverId) public async Task<IActionResult> handleSearch([FromQuery] string searchTerm, string serverId)
{ {
var sessionToken = Request.Cookies["session"];
if (!await _auth.IsAuthenticated(sessionToken))
{
return Unauthorized();
}
var decodedSearchTerm = HttpUtility.UrlDecode(searchTerm); var decodedSearchTerm = HttpUtility.UrlDecode(searchTerm);
var results = await _service.Search(decodedSearchTerm, serverId); var results = await _service.Search(decodedSearchTerm, serverId);

View file

@ -8,18 +8,27 @@ namespace JellyGlass.Controllers;
public class ServersController : ControllerBase public class ServersController : ControllerBase
{ {
private ILogger<ServersController> _logger; private ILogger<ServersController> _logger;
private IServerService _service; private IServerService _serverService;
private IAuthService _authService;
public ServersController(ILogger<ServersController> logger, IServerService service) public ServersController(ILogger<ServersController> logger, IServerService serverService, IAuthService authService)
{ {
_logger = logger; _logger = logger;
_service = service; _serverService = serverService;
_authService = authService;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> getServers() public async Task<IActionResult> 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); return Ok(servers);
} }

View file

@ -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) { }
}

View file

@ -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) { }
}

View file

@ -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) { }
}

View file

@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -0,0 +1,88 @@
// <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("20260303190433_auth")]
partial class auth
{
/// <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<string>("Id")
.HasColumnType("TEXT");
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.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,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JellyGlassBackend.Migrations
{
/// <inheritdoc />
public partial class auth : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Logins",
columns: table => new
{
Username = table.Column<string>(type: "TEXT", nullable: false),
HashedPassword = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Logins", x => x.Username);
});
migrationBuilder.CreateTable(
name: "Sessions",
columns: table => new
{
SessionToken = table.Column<string>(type: "TEXT", nullable: false),
LoginUsername = table.Column<string>(type: "TEXT", nullable: true),
ExpiresOn = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Sessions");
migrationBuilder.DropTable(
name: "Logins");
}
}
}

View file

@ -1,4 +1,5 @@
// <auto-generated /> // <auto-generated />
using System;
using JellyGlass.Repositories; using JellyGlass.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@ -37,6 +38,47 @@ namespace JellyGlassBackend.Migrations
b.ToTable("Servers"); b.ToTable("Servers");
}); });
modelBuilder.Entity("JellyGlass.Models.UserLogin", b =>
{
b.Property<string>("Username")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
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 #pragma warning restore 612, 618
} }
} }

View file

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

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -24,11 +24,15 @@ else
builder.Services.AddSqlite<DatabaseContext>(dbConnectionString); builder.Services.AddSqlite<DatabaseContext>(dbConnectionString);
builder.Services.AddTransient<ILibraryService, LibraryService>();
builder.Services.AddTransient<IServerRepository, ServerRepository>(); builder.Services.AddTransient<IServerRepository, ServerRepository>();
builder.Services.AddTransient<ILoginRepository, LoginRepository>();
builder.Services.AddTransient<ISessionRepository, SessionRepository>();
builder.Services.AddTransient<ILibraryService, LibraryService>();
builder.Services.AddScoped<IClientService, ClientService>(); builder.Services.AddScoped<IClientService, ClientService>();
builder.Services.AddTransient<IServerService, ServerService>(); builder.Services.AddTransient<IServerService, ServerService>();
builder.Services.AddTransient<ISearchService, SearchService>(); builder.Services.AddTransient<ISearchService, SearchService>();
builder.Services.AddTransient<IAuthService, AuthService>();
var app = builder.Build(); var app = builder.Build();

View file

@ -5,11 +5,13 @@ namespace JellyGlass.Repositories;
public class DatabaseContext : DbContext public class DatabaseContext : DbContext
{ {
public DatabaseContext(DbContextOptions options) public DatabaseContext(DbContextOptions<DatabaseContext> options)
: base(options) : base(options)
{ {
} }
public DbSet<Server> Servers { get; set; } public DbSet<Server> Servers { get; set; }
public DbSet<UserLogin> Logins { get; set; }
public DbSet<UserSession> Sessions { get; set; }
} }

View file

@ -0,0 +1,11 @@
using JellyGlass.Models;
namespace JellyGlass.Repositories;
public interface ISessionRepository
{
public Task<UserSession> CreateUserSession(UserLogin user);
public Task<UserSession?> GetUserSession(string sessionToken);
public Task RefreshSessionExpiry(string sessionToken);
public Task DeleteSession(string sessionToken);
}

View file

@ -0,0 +1,8 @@
using JellyGlass.Models;
namespace JellyGlass.Repositories;
public interface ILoginRepository
{
public Task<UserLogin> GetUserLogin(string username);
}

View file

@ -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<UserLogin> 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;
}
}

View file

@ -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<UserSession> 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<UserSession?> 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
}
}

View file

@ -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<UserSessionDTO> 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<bool> 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;
}
}
}

View file

@ -0,0 +1,9 @@
using JellyGlass.Models;
namespace JellyGlass.Services;
public interface IAuthService
{
public Task<UserSessionDTO> AuthenticateUser(string username, string password);
public Task<bool> IsAuthenticated(string? sessionToken);
}

View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"axios": "^1.13.5", "axios": "^1.13.5",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"js-cookie": "^3.0.5",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@ -19,6 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -1819,6 +1821,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -3285,6 +3294,15 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View file

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"axios": "^1.13.5", "axios": "^1.13.5",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"js-cookie": "^3.0.5",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@ -21,6 +22,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View file

@ -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 styles from "./Navbar.module.scss";
import Searchbar from "../Searchbar/Searchbar"; import Searchbar from "../Searchbar/Searchbar";
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import Cookies from "js-cookie";
const Navbar = () => { const Navbar = () => {
const [searchText, setSearchText] = useState<string>(""); const [searchText, setSearchText] = useState<string>("");
const navigate = useNavigate(); const navigate = useNavigate();
const session = Cookies.get("session");
function onLogout() {
Cookies.remove("session");
navigate("/login");
}
function onSearch() { function onSearch() {
navigate(`/search?search=${searchText}`); navigate(`/search?search=${searchText}`);
@ -22,9 +29,10 @@ const Navbar = () => {
<BsNavbar.Toggle /> <BsNavbar.Toggle />
<BsNavbar.Collapse> <BsNavbar.Collapse>
<Container className="justify-content-center d-flex"> <Container className="justify-content-center d-flex">
<Searchbar text={searchText} setText={setSearchText} onSearch={onSearch} /> <Searchbar text={searchText} setText={setSearchText} onSearch={onSearch} enabled={session !== undefined} />
</Container> </Container>
</BsNavbar.Collapse> </BsNavbar.Collapse>
{session && <Button onClick={onLogout}>Logout</Button>}
</Container> </Container>
</BsNavbar> </BsNavbar>
) )

View file

@ -3,3 +3,9 @@
padding: 5px; padding: 5px;
width: 100%; width: 100%;
} }
.searchbarDisabled {
@extend .searchbar;
background-color: lightgray;
cursor: not-allowed;
}

View file

@ -5,9 +5,10 @@ interface SearchbarProps {
text: string, text: string,
setText: (text: string) => void; setText: (text: string) => void;
onSearch?: () => void; onSearch?: () => void;
enabled?: boolean;
} }
const Searchbar = ({ text, setText, onSearch }: SearchbarProps) => { const Searchbar = ({ text, setText, onSearch, enabled = true }: SearchbarProps) => {
function onKeyPressed(event: React.KeyboardEvent<HTMLInputElement>) { function onKeyPressed(event: React.KeyboardEvent<HTMLInputElement>) {
if (onSearch === undefined) { if (onSearch === undefined) {
@ -20,7 +21,7 @@ const Searchbar = ({ text, setText, onSearch }: SearchbarProps) => {
} }
return ( return (
<input className={styles.searchbar} type="text" placeholder="Search" value={text} onChange={e => setText(e.target.value)} onKeyUp={onKeyPressed} /> <input className={enabled ? styles.searchbar : styles.searchbarDisabled} type="text" placeholder="Search" value={text} onChange={e => setText(e.target.value)} onKeyUp={onKeyPressed} disabled={!enabled} />
) )
} }

View file

@ -11,6 +11,9 @@ const ServerList = () => {
setServers(serverList); setServers(serverList);
}).catch(err => { }).catch(err => {
setServers([]); setServers([]);
if (err.response && err.response.status === 401) {
return;
}
alert(err); alert(err);
}) })
}, []); }, []);

13
frontend/src/Lib/Auth.ts Normal file
View file

@ -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<Session>(`${apiUrl}/auth`, `username=${encodeURI(username)}&password=${encodeURI(password)}`);
return response.data;
}

View file

@ -11,7 +11,7 @@ export interface SearchResult {
} }
export const search = async (searchTerm: string, serverId: string): Promise<Array<SearchResult>> => { export const search = async (searchTerm: string, serverId: string): Promise<Array<SearchResult>> => {
const response = await axios.get<Array<SearchResult>>(encodeURI(`${apiUrl}/search?searchTerm=${searchTerm}&serverId=${serverId}`)); const response = await axios.get<Array<SearchResult>>(encodeURI(`${apiUrl}/search?searchTerm=${searchTerm}&serverId=${serverId}`), { withCredentials: true });
return response.data; return response.data;
} }

View file

@ -11,7 +11,7 @@ export interface Server {
export const getServerList = async (): Promise<Array<Server>> => { export const getServerList = async (): Promise<Array<Server>> => {
console.log("fetching server list"); console.log("fetching server list");
const response = await axios.get<Array<Server>>(`${apiUrl}/servers`); const response = await axios.get<Array<Server>>(`${apiUrl}/servers`, { withCredentials: true });
console.log(response); console.log(response);

View file

@ -0,0 +1,9 @@
.form {
margin: auto;
margin-top: 50px;
max-width: 400px;
}
.formButton {
margin-top: 10px;
}

View file

@ -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<string>("");
const [password, setPassword] = useState<string>("");
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<HTMLFormElement>) {
if (e.key === "Enter") {
onSubmit();
}
}
return (
<div className={styles.form}>
<Form onKeyUp={onFormKeypress}>
<h3>Login</h3>
<FormGroup>
<Form.Label>Username</Form.Label>
<Form.Control type="text" value={username} onChange={e => setUsername(e.target.value)} />
</FormGroup>
<FormGroup>
<Form.Label>Password</Form.Label>
<Form.Control type="password" value={password} onChange={e => setPassword(e.target.value)} />
</FormGroup>
<Button className={styles.formButton} variant="primary" onClick={onSubmit}>Login</Button>
</Form>
</div>
);
}
export default Login;

View file

@ -3,35 +3,51 @@ import { getServerList, type Server } from "../../Lib/Servers";
import ServerSearch from "../../Components/ServerSearch/ServerSearch"; import ServerSearch from "../../Components/ServerSearch/ServerSearch";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Spinner } from "react-bootstrap"; import { Spinner } from "react-bootstrap";
import Cookies from "js-cookie";
const Search = () => { const Search = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [servers, setServers] = useState<Array<Server>>([]); const [servers, setServers] = useState<Array<Server>>([]);
const navigate = useNavigate(); const navigate = useNavigate();
const sessionCookie = Cookies.get("session");
const searchTerm = searchParams.get("search") || ""; const searchTerm = searchParams.get("search") || "";
useEffect(() => { useEffect(() => {
if (!sessionCookie) {
navigate("/login");
return;
}
if (searchTerm === "") { if (searchTerm === "") {
alert(`Error search term missing: ${searchTerm}`); alert(`Error search term missing: ${searchTerm}`);
navigate("/"); navigate("/");
} }
if (servers.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setServers([]);
}
getServerList().then(servers => { getServerList().then(servers => {
if (servers.length === 0) { if (servers.length === 0) {
alert("No servers found"); alert("No servers found");
} }
setServers(servers);
const workingServers: Array<Server> = [];
servers.forEach(s => {
if (!s.errored) {
workingServers.push(s);
}
})
if (workingServers.length === 0) {
alert("No working servers");
navigate("/");
}
setServers(workingServers);
}).catch(e => { }).catch(e => {
alert(e); alert(e);
}); });
}, [searchTerm]); }, [searchTerm, navigate]);
return ( return (
<> <>

View file

@ -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 Index = () => {
const navigate = useNavigate();
const sessionCookie = Cookies.get("session");
useEffect(() => {
if (!sessionCookie) {
navigate("/login");
}
}, [navigate, sessionCookie]);
return ( return (
<div style={{ width: "100%", padding: "20px", display: "flex", flexDirection: "column", alignItems: "center" }}> <div style={{ width: "100%", padding: "20px", display: "flex", flexDirection: "column", alignItems: "center" }}>
<h1>Available Servers</h1> <h1>Available Servers</h1>

View file

@ -5,6 +5,7 @@ import Index from './index.tsx'
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import Navbar from './Components/Navbar/Navbar.tsx' import Navbar from './Components/Navbar/Navbar.tsx'
import Search from './Pages/Search/Search.tsx'; import Search from './Pages/Search/Search.tsx';
import Login from './Pages/Login/Login.tsx';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
@ -13,6 +14,7 @@ createRoot(document.getElementById('root')!).render(
<Routes> <Routes>
<Route path="/" element={<Index />} /> <Route path="/" element={<Index />} />
<Route path="/search" element={<Search />} /> <Route path="/search" element={<Search />} />
<Route path="/login" element={<Login />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,