v1.0
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/.idea
|
||||
/projects
|
||||
docker-compose.yml
|
||||
25
ProjectManager.Backend/.dockerignore
Normal file
25
ProjectManager.Backend/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
3
ProjectManager.Backend/.gitignore
vendored
Normal file
3
ProjectManager.Backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
obj
|
||||
bin
|
||||
appsettings.Development.json
|
||||
63
ProjectManager.Backend/Apis/IDockerApi.cs
Normal file
63
ProjectManager.Backend/Apis/IDockerApi.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ProjectManager.Backend.Options;
|
||||
|
||||
namespace ProjectManager.Backend.Apis;
|
||||
|
||||
public interface IDockerApi {
|
||||
public Task<string> CreateContainer(string image, int port, string hostVolumePath, string name);
|
||||
public Task StartContainer(string containerId);
|
||||
public Task StopContainer(string containerId);
|
||||
public Task DeleteContainer(string containerId);
|
||||
public Task<bool> IsContainerStarted(string containerId);
|
||||
}
|
||||
|
||||
public sealed class DockerApi : IDockerApi {
|
||||
private readonly DockerClient _client;
|
||||
|
||||
public DockerApi(IOptions<GeneralOptions> options) {
|
||||
_client = new DockerClientConfiguration(new Uri(options.Value.DockerPath)).CreateClient();
|
||||
}
|
||||
|
||||
public async Task<string> CreateContainer(string image, int port, string hostVolumePath, string name) {
|
||||
await _client.Images.CreateImageAsync(new() {
|
||||
FromImage = image
|
||||
}, null, new Progress<JSONMessage>());
|
||||
|
||||
var container = await _client.Containers.CreateContainerAsync(new CreateContainerParameters {
|
||||
Image = image,
|
||||
ExposedPorts = new Dictionary<string, EmptyStruct> {
|
||||
{ "8090/tcp", default }
|
||||
},
|
||||
HostConfig = new HostConfig {
|
||||
PortBindings = new Dictionary<string, IList<PortBinding>> {
|
||||
{ "8090/tcp", new List<PortBinding> { new() { HostPort = port.ToString() } } }
|
||||
},
|
||||
Binds = new List<string> {
|
||||
$"{hostVolumePath}:/pb_data"
|
||||
}
|
||||
},
|
||||
Name = name
|
||||
});
|
||||
return container.ID;
|
||||
}
|
||||
|
||||
public async Task StartContainer(string containerId) {
|
||||
await _client.Containers.StartContainerAsync(containerId, new());
|
||||
}
|
||||
|
||||
public async Task StopContainer(string containerId) {
|
||||
await _client.Containers.StopContainerAsync(containerId, new());
|
||||
}
|
||||
|
||||
public async Task DeleteContainer(string containerId) {
|
||||
await _client.Containers.RemoveContainerAsync(containerId, new());
|
||||
}
|
||||
|
||||
public async Task<bool> IsContainerStarted(string containerId) {
|
||||
var containers = await _client.Containers.ListContainersAsync(new());
|
||||
var container = containers.SingleOrDefault(c => c.ID == containerId);
|
||||
return container != null;
|
||||
}
|
||||
}
|
||||
89
ProjectManager.Backend/Apis/IProjectApi.cs
Normal file
89
ProjectManager.Backend/Apis/IProjectApi.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ProjectManager.Backend.Entities;
|
||||
using ProjectManager.Backend.Options;
|
||||
|
||||
namespace ProjectManager.Backend.Apis;
|
||||
|
||||
public interface IProjectApi {
|
||||
public Project[] GetProjects(string userId);
|
||||
public Project GetProject(string projectId);
|
||||
public Task<string> AddProject(string name, string ownerId);
|
||||
public bool EditProject(string projectId, string name);
|
||||
public Task DeleteProject(string projectId);
|
||||
}
|
||||
|
||||
public class ProjectApi : IProjectApi {
|
||||
private readonly DatabaseContext _context;
|
||||
private readonly GeneralOptions _options;
|
||||
private readonly IDockerApi _docker;
|
||||
private readonly IProxyApi _proxy;
|
||||
|
||||
public ProjectApi(DatabaseContext context, IOptions<GeneralOptions> options, IDockerApi docker, IProxyApi proxy) {
|
||||
_context = context;
|
||||
_options = options.Value;
|
||||
_docker = docker;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
public Project[] GetProjects(string userId) {
|
||||
return _context.Projects.Where(project => project.OwnerId == userId).ToArray();
|
||||
}
|
||||
|
||||
public Project GetProject(string projectId) {
|
||||
return _context.Projects.SingleOrDefault(project => project.ProjectId == projectId);
|
||||
}
|
||||
|
||||
public async Task<string> AddProject(string name, string ownerId) {
|
||||
if (name.Length > 255) return null;
|
||||
|
||||
// Get available port
|
||||
var split = _options.PortRange.Split('-');
|
||||
var portRange = Enumerable.Range(int.Parse(split[0]), int.Parse(split[1]));
|
||||
var usedPorts = _context.Projects.Select(p => p.Port).ToArray();
|
||||
var port = portRange.FirstOrDefault(port => !usedPorts.Contains(port));
|
||||
if (port == 0) return null;
|
||||
|
||||
var project = new Project {
|
||||
ProjectId = Guid.NewGuid().ToString(),
|
||||
OwnerId = ownerId,
|
||||
Name = name,
|
||||
Port = port
|
||||
};
|
||||
|
||||
var container = await _docker.CreateContainer($"ghcr.io/muchobien/pocketbase:{_options.PocketBaseVersion}", port, _options.Root + project.ProjectId, $"{project.Name}_{project.ProjectId}");
|
||||
await _docker.StartContainer(container);
|
||||
project.ContainerName = container;
|
||||
|
||||
var (proxyId, certificateId) = await _proxy.AddLocation(project.ProjectId, project.Port);
|
||||
project.ProxyId = proxyId;
|
||||
project.CertificateId = certificateId;
|
||||
|
||||
_context.Projects.Add(project);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return project.ProjectId;
|
||||
}
|
||||
|
||||
public bool EditProject(string projectId, string name) {
|
||||
if (name.Length > 255) return false;
|
||||
var project = GetProject(projectId);
|
||||
if (project == null) return false;
|
||||
project.Name = name;
|
||||
_context.Projects.Update(project);
|
||||
_context.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task DeleteProject(string projectId) {
|
||||
if (!_context.Projects.Any(project => project.ProjectId == projectId)) return;
|
||||
|
||||
var project = _context.Projects.Single(project => project.ProjectId == projectId);
|
||||
await _docker.StopContainer(project.ContainerName);
|
||||
await _docker.DeleteContainer(project.ContainerName);
|
||||
|
||||
await _proxy.RemoveLocation(project.ProxyId, project.CertificateId);
|
||||
|
||||
_context.Projects.Remove(project);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
84
ProjectManager.Backend/Apis/IProxyApi.cs
Normal file
84
ProjectManager.Backend/Apis/IProxyApi.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ProjectManager.Backend.Options;
|
||||
|
||||
namespace ProjectManager.Backend.Apis;
|
||||
|
||||
public interface IProxyApi {
|
||||
public Task<(int, int)> AddLocation(string projectId, int port);
|
||||
public Task RemoveLocation(int proxyId, int certificateId);
|
||||
}
|
||||
|
||||
public sealed class ProxyApi : IProxyApi {
|
||||
private readonly HttpClient _client;
|
||||
private readonly ProxyOptions _options;
|
||||
|
||||
public ProxyApi(IOptions<ProxyOptions> options) {
|
||||
_client = new HttpClient();
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
private async Task Login() {
|
||||
var result = await _client.PostAsJsonAsync(_options.Url + "/api/tokens",
|
||||
new ProxyAuth(_options.Email, _options.Password), JsonSerializerOptions.Default);
|
||||
var response = await result.Content.ReadFromJsonAsync<TokenResponse>();
|
||||
_client.DefaultRequestHeaders.Clear();
|
||||
_client.DefaultRequestHeaders.Add("Authorization", $"Bearer {response?.token}");
|
||||
}
|
||||
|
||||
public async Task<(int, int)> AddLocation(string projectId, int port) {
|
||||
if (!_options.Enable) return (-1, -1);
|
||||
await Login();
|
||||
var result = await _client.PostAsJsonAsync(_options.Url + "/api/nginx/proxy-hosts",
|
||||
new CreateData($"{projectId}.{_options.Domain}", _options.Host, port, _options.Email));
|
||||
dynamic data = await result.Content.ReadFromJsonAsync<ExpandoObject>();
|
||||
if (data == null) return (-1, -1);
|
||||
int id = Convert.ToInt32($"{data.id}");
|
||||
int certificateId = Convert.ToInt32($"{data.certificate_id}");
|
||||
return (id, certificateId);
|
||||
}
|
||||
|
||||
public async Task RemoveLocation(int proxyId, int certificateId) {
|
||||
if (proxyId == -1 || certificateId == -1) return;
|
||||
await Login();
|
||||
await _client.DeleteAsync(_options.Url + "/api/nginx/proxy-hosts/" + proxyId);
|
||||
await _client.DeleteAsync(_options.Url + "/api/nginx/certificates/" + certificateId);
|
||||
}
|
||||
|
||||
private sealed record ProxyAuth(string identity, string secret);
|
||||
private sealed record TokenResponse(string token, string expires);
|
||||
private sealed class CreateData {
|
||||
public string access_list_id { get; set; } = "0";
|
||||
public string advanced_config { get; set; } = " location / {\r\n proxy_pass http://%docker_ip%;\r\n proxy_hide_header X-Frame-Options;\r\n }";
|
||||
public bool allow_websocket_upgrade { get; set; } = true;
|
||||
public bool block_exploits { get; set; } = true;
|
||||
public bool caching_enabled { get; set; } = true;
|
||||
public string certificate_id { get; set; } = "new";
|
||||
public string[] domain_names { get; set; }
|
||||
public string forward_host { get; set; }
|
||||
public int forward_port { get; set; }
|
||||
public string forward_scheme { get; set; } = "http";
|
||||
public bool hsts_enabled { get; set; } = false;
|
||||
public bool hsts_subdomains { get; set; } = false;
|
||||
public bool http2_support { get; set; } = true;
|
||||
public string[] locations { get; set; } = Array.Empty<string>();
|
||||
public bool ssl_forced { get; set; } = true;
|
||||
public SslMeta meta { get; set; }
|
||||
|
||||
public CreateData(string domain, string ip, int port, string email) {
|
||||
domain_names = new[] { domain };
|
||||
forward_host = ip;
|
||||
forward_port = port;
|
||||
meta = new SslMeta {
|
||||
letsencrypt_email = email
|
||||
};
|
||||
advanced_config = advanced_config.Replace("%docker_ip%", $"{ip}:{port}");
|
||||
}
|
||||
}
|
||||
private sealed class SslMeta {
|
||||
public bool dns_challenge { get; set; } = false;
|
||||
public bool letsencrypt_agree { get; set; } = true;
|
||||
public string letsencrypt_email { get; set; }
|
||||
}
|
||||
}
|
||||
66
ProjectManager.Backend/Apis/ITokenApi.cs
Normal file
66
ProjectManager.Backend/Apis/ITokenApi.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ProjectManager.Backend.Entities;
|
||||
using ProjectManager.Backend.Options;
|
||||
|
||||
namespace ProjectManager.Backend.Apis;
|
||||
|
||||
public interface ITokenApi {
|
||||
public string GetValidToken(string userId, string clientIp);
|
||||
public bool ValidateToken(string tokenId, string clientIp);
|
||||
public User GetUserFromToken(string tokenId);
|
||||
}
|
||||
|
||||
public sealed class TokenApi : ITokenApi {
|
||||
private readonly DatabaseContext _context;
|
||||
private readonly GeneralOptions _options;
|
||||
private readonly IUserApi _users;
|
||||
|
||||
public TokenApi(DatabaseContext context, IOptions<GeneralOptions> options, IUserApi users) {
|
||||
_context = context;
|
||||
_options = options.Value;
|
||||
_users = users;
|
||||
}
|
||||
|
||||
public string GetValidToken(string userId, string clientIp) {
|
||||
var token = _context.Tokens.SingleOrDefault(token => token.UserId == userId && token.ClientIp == clientIp);
|
||||
|
||||
if (token == null) {
|
||||
token = new() {
|
||||
TokenId = Guid.NewGuid().ToString(),
|
||||
UserId = userId,
|
||||
ClientIp = clientIp,
|
||||
Created = DateTime.Now
|
||||
};
|
||||
_context.Tokens.Add(token);
|
||||
_context.SaveChanges();
|
||||
return token.TokenId;
|
||||
}
|
||||
|
||||
if (!ValidateToken(token.TokenId, clientIp)) {
|
||||
_context.Tokens.Remove(token);
|
||||
token = new() {
|
||||
TokenId = Guid.NewGuid().ToString(),
|
||||
UserId = userId,
|
||||
ClientIp = clientIp,
|
||||
Created = DateTime.Now
|
||||
};
|
||||
_context.Tokens.Add(token);
|
||||
_context.SaveChanges();
|
||||
return token.TokenId;
|
||||
}
|
||||
|
||||
return token.TokenId;
|
||||
}
|
||||
|
||||
public bool ValidateToken(string tokenId, string clientIp) {
|
||||
if (tokenId is null || clientIp is null) return false;
|
||||
var token = _context.Tokens.SingleOrDefault(token => token.TokenId == tokenId && token.ClientIp == clientIp);
|
||||
if (token is null) return false;
|
||||
if (_context.Users.SingleOrDefault(user => user.UserId == token.UserId) == null) return false;
|
||||
return DateTime.Now - token.Created < TimeSpan.FromDays(_options.TokenTimeInDays);
|
||||
}
|
||||
|
||||
public User GetUserFromToken(string tokenId) {
|
||||
return _users.GetUser(_context.Tokens.SingleOrDefault(token => token.TokenId == tokenId)?.UserId);
|
||||
}
|
||||
}
|
||||
121
ProjectManager.Backend/Apis/IUserApi.cs
Normal file
121
ProjectManager.Backend/Apis/IUserApi.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ProjectManager.Backend.Entities;
|
||||
using ProjectManager.Backend.Options;
|
||||
|
||||
namespace ProjectManager.Backend.Apis;
|
||||
|
||||
public interface IUserApi {
|
||||
public User Login(User login);
|
||||
public User Register(User register);
|
||||
public User GetUser(string userId);
|
||||
public IEnumerable<User> GetUsers();
|
||||
public bool UpdateUser(User update);
|
||||
public void DeleteUser(string userId);
|
||||
public bool CanCreateProject(string userId);
|
||||
}
|
||||
|
||||
public sealed class UserApi : IUserApi {
|
||||
private readonly DatabaseContext _context;
|
||||
private readonly IProjectApi _projects;
|
||||
private readonly GeneralOptions _options;
|
||||
|
||||
public UserApi(DatabaseContext context, IProjectApi projects, IOptions<GeneralOptions> options) {
|
||||
_context = context;
|
||||
_projects = projects;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public User Login(User login) {
|
||||
if (string.IsNullOrEmpty(login.Email) || string.IsNullOrEmpty(login.Password)) return null;
|
||||
var hash = Hash128(login.Password, login.Email);
|
||||
return _context.Users.SingleOrDefault(user => user.Email == login.Email && user.Password == hash);
|
||||
}
|
||||
|
||||
public User Register(User register) {
|
||||
if (string.IsNullOrEmpty(register.Username) ||
|
||||
string.IsNullOrEmpty(register.Email) ||
|
||||
string.IsNullOrEmpty(register.Password)) return null;
|
||||
|
||||
if (_context.Users.Any(user => user.Email == register.Email || user.Username == register.Username))
|
||||
return null;
|
||||
|
||||
if (register.Email.Length > 255 || register.Username.Length > 255 || register.Password.Length > 255)
|
||||
return null;
|
||||
|
||||
if (!register.Email.Contains('@') || !register.Email.Contains('.')) return null;
|
||||
if (!register.Password.Any(char.IsLetter) || !register.Password.Any(char.IsDigit) || register.Password.Length < 8) return null;
|
||||
|
||||
User user = new() {
|
||||
UserId = Guid.NewGuid().ToString(),
|
||||
Email = register.Email,
|
||||
Username = register.Username,
|
||||
Password = Hash128(register.Password, register.Email),
|
||||
MaxProjects = _options.MaxProjects
|
||||
};
|
||||
|
||||
_context.Users.Add(user);
|
||||
_context.SaveChanges();
|
||||
return user;
|
||||
}
|
||||
|
||||
public User GetUser(string userId) {
|
||||
return _context.Users.SingleOrDefault(user => user.UserId == userId);
|
||||
}
|
||||
|
||||
public IEnumerable<User> GetUsers() {
|
||||
return _context.Users;
|
||||
}
|
||||
|
||||
public bool UpdateUser(User update) {
|
||||
if (_context.Users.Any(user => user.UserId != update.UserId && (user.Email == update.Email || user.Username == update.Username)))
|
||||
return false;
|
||||
|
||||
if (update.Email.Length > 255 || update.Username.Length > 255 || update.Password.Length > 255)
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Email) && (!update.Email.Contains('@') || !update.Email.Contains('.'))) return false;
|
||||
if (!string.IsNullOrEmpty(update.Password) && (!update.Password.Any(char.IsLetter) || !update.Password.Any(char.IsDigit) || update.Password.Length < 8)) return false;
|
||||
|
||||
var user = _context.Users.SingleOrDefault(user => user.UserId == update.UserId);
|
||||
if (user == null) return false;
|
||||
if (!string.IsNullOrEmpty(update.Email)) user.Email = update.Email;
|
||||
if (!string.IsNullOrEmpty(update.Username)) user.Username = update.Username;
|
||||
if (!string.IsNullOrEmpty(update.Password)) user.Password = Hash128(update.Password, user.Email);
|
||||
_context.Users.Update(user);
|
||||
_context.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void DeleteUser(string userId) {
|
||||
if (!_context.Users.Any(user => user.UserId == userId)) return;
|
||||
var user = _context.Users.Single(user => user.UserId == userId);
|
||||
|
||||
var projects = _projects.GetProjects(userId);
|
||||
foreach (var project in projects) {
|
||||
_projects.DeleteProject(project.ProjectId);
|
||||
}
|
||||
|
||||
_context.Users.Remove(user);
|
||||
_context.SaveChanges();
|
||||
}
|
||||
|
||||
public bool CanCreateProject(string userId) {
|
||||
return GetUser(userId).MaxProjects > _projects.GetProjects(userId).Length;
|
||||
}
|
||||
|
||||
private static string Hash128(string plainText, string salt) {
|
||||
try {
|
||||
string hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2(
|
||||
password: plainText,
|
||||
salt: Encoding.Default.GetBytes(salt),
|
||||
prf: KeyDerivationPrf.HMACSHA256,
|
||||
iterationCount: 100000,
|
||||
numBytesRequested: 256 / 8
|
||||
));
|
||||
|
||||
return hashed;
|
||||
} catch (Exception) { return ""; }
|
||||
}
|
||||
}
|
||||
119
ProjectManager.Backend/Controllers/ProjectController.cs
Normal file
119
ProjectManager.Backend/Controllers/ProjectController.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ProjectManager.Backend.Apis;
|
||||
using ProjectManager.Backend.Entities;
|
||||
using ProjectManager.Backend.Options;
|
||||
using ProjectManager.Backend.Security;
|
||||
|
||||
namespace ProjectManager.Backend.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("projects")]
|
||||
public class ProjectController : ControllerBase {
|
||||
private readonly IProjectApi _projects;
|
||||
private readonly ITokenContext _context;
|
||||
private readonly IUserApi _users;
|
||||
private readonly IDockerApi _docker;
|
||||
private readonly ProxyOptions _options;
|
||||
|
||||
public ProjectController(
|
||||
IProjectApi projects,
|
||||
ITokenContext context,
|
||||
IUserApi users,
|
||||
IDockerApi docker,
|
||||
IOptions<ProxyOptions> options
|
||||
) {
|
||||
_projects = projects;
|
||||
_context = context;
|
||||
_users = users;
|
||||
_docker = docker;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetProjects() {
|
||||
var projects = _projects.GetProjects(_context.UserId);
|
||||
var running = new bool[projects.Length];
|
||||
for (int i = 0; i < projects.Length; i++)
|
||||
running[i] = await _docker.IsContainerStarted(projects[i].ContainerName);
|
||||
return Ok(new { projects, running });
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("{projectId}")]
|
||||
public IActionResult GetProject(string projectId) {
|
||||
var project = _projects.GetProject(projectId);
|
||||
if (project == null) return NotFound();
|
||||
if (project.OwnerId != _context.UserId) return Unauthorized();
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddProject([FromBody] ProjectEdit edit) {
|
||||
if (!_users.CanCreateProject(_context.UserId)) return Forbid();
|
||||
var projectId = await _projects.AddProject(edit.Name, _context.UserId);
|
||||
if (projectId == null) return BadRequest();
|
||||
return Ok(new { ProjectId = projectId });
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpDelete("{projectId}")]
|
||||
public async Task<IActionResult> DeleteProject(string projectId) {
|
||||
var project = _projects.GetProject(projectId);
|
||||
if (project == null) return NotFound();
|
||||
if (project.OwnerId != _context.UserId) return Unauthorized();
|
||||
await _projects.DeleteProject(projectId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpPut("{projectId}")]
|
||||
public IActionResult EditProject(string projectId, [FromBody] ProjectEdit edit) {
|
||||
var project = _projects.GetProject(projectId);
|
||||
if (project == null) return NotFound();
|
||||
if (project.OwnerId != _context.UserId) return Unauthorized();
|
||||
_projects.EditProject(projectId, edit.Name);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("{projectId}/url")]
|
||||
public IActionResult GetProjectUrl(string projectId) {
|
||||
var project = _projects.GetProject(projectId);
|
||||
if (project == null) return NotFound();
|
||||
if (project.OwnerId != _context.UserId) return Unauthorized();
|
||||
if (_options.Enable) return Redirect($"https://{projectId}.{_options.Domain}/_/");
|
||||
return Redirect($"http://{_options.Host}:{project.Port}/_/");
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("{projectId}/start")]
|
||||
public async Task<IActionResult> StartProject(string projectId) {
|
||||
var project = _projects.GetProject(projectId);
|
||||
if (project == null) return NotFound();
|
||||
if (project.OwnerId != _context.UserId) return Unauthorized();
|
||||
await _docker.StartContainer(project.ContainerName);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("{projectId}/stop")]
|
||||
public async Task<IActionResult> StopProject(string projectId) {
|
||||
var project = _projects.GetProject(projectId);
|
||||
if (project == null) return NotFound();
|
||||
if (project.OwnerId != _context.UserId) return Unauthorized();
|
||||
await _docker.StopContainer(project.ContainerName);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("{projectId}/status")]
|
||||
public async Task<IActionResult> ProjectStatus(string projectId) {
|
||||
var project = _projects.GetProject(projectId);
|
||||
if (project == null) return NotFound();
|
||||
if (project.OwnerId != _context.UserId) return Unauthorized();
|
||||
return Ok(new { Running = await _docker.IsContainerStarted(project.ContainerName) });
|
||||
}
|
||||
}
|
||||
87
ProjectManager.Backend/Controllers/UserController.cs
Normal file
87
ProjectManager.Backend/Controllers/UserController.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ProjectManager.Backend.Entities;
|
||||
using ProjectManager.Backend.Security;
|
||||
using ProjectManager.Backend.Apis;
|
||||
|
||||
namespace ProjectManager.Backend.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("users")]
|
||||
public sealed class UserController : ControllerBase {
|
||||
private readonly IUserApi _users;
|
||||
private readonly ITokenApi _tokens;
|
||||
private readonly ITokenContext _context;
|
||||
|
||||
public UserController(IUserApi users, ITokenApi tokens, ITokenContext context) {
|
||||
_users = users;
|
||||
_tokens = tokens;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public IActionResult Login([FromBody] User login) {
|
||||
var user = _users.Login(login);
|
||||
if (user == null) return Conflict();
|
||||
return Ok(new {Token = _tokens.GetValidToken(user.UserId, HttpContext.Connection.RemoteIpAddress?.ToString())});
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public IActionResult Register([FromBody] User register) {
|
||||
var user = _users.Register(register);
|
||||
if (user is null) return Conflict();
|
||||
return Ok(new {Token = _tokens.GetValidToken(user.UserId, HttpContext.Connection.RemoteIpAddress?.ToString())});
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("token")]
|
||||
public IActionResult CheckToken() {
|
||||
return Ok(new {Valid = true});
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("me")]
|
||||
public IActionResult GetMe() {
|
||||
return GetUser(_context.UserId);
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet]
|
||||
public IActionResult GetUsers() {
|
||||
return Ok(_users.GetUsers().Select(user => new User {
|
||||
UserId = user.UserId,
|
||||
Email = user.Email,
|
||||
Username = user.Username
|
||||
}));
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpGet("{userId}")]
|
||||
public IActionResult GetUser(string userId) {
|
||||
var user = _users.GetUser(userId);
|
||||
|
||||
if (user is null) return NotFound();
|
||||
|
||||
user = new() {
|
||||
UserId = user.UserId,
|
||||
Email = user.Email,
|
||||
Username = user.Username
|
||||
};
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpPut]
|
||||
public IActionResult UpdateUser([FromBody] User user) {
|
||||
if (_context.UserId != user.UserId) return Forbid();
|
||||
if (!_users.UpdateUser(user)) return BadRequest();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Authorized]
|
||||
[HttpDelete]
|
||||
public IActionResult DeleteUser() {
|
||||
_users.DeleteUser(_context.UserId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
}
|
||||
49
ProjectManager.Backend/DatabaseContext.cs
Normal file
49
ProjectManager.Backend/DatabaseContext.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ProjectManager.Backend.Entities;
|
||||
using ProjectManager.Backend.Options;
|
||||
|
||||
namespace ProjectManager.Backend;
|
||||
|
||||
public class DatabaseContext : DbContext {
|
||||
private readonly GeneralOptions _options;
|
||||
|
||||
public DbSet<User> Users { get; set; }
|
||||
public DbSet<UserToken> Tokens { get; set; }
|
||||
public DbSet<Project> Projects { get; set; }
|
||||
|
||||
public DatabaseContext(IOptions<GeneralOptions> options) {
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
|
||||
optionsBuilder.UseMySQL(_options.Database);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<User>(entry => {
|
||||
entry.HasKey(e => e.UserId);
|
||||
entry.Property(e => e.Email);
|
||||
entry.Property(e => e.Username);
|
||||
entry.Property(e => e.Password);
|
||||
entry.Property(e => e.MaxProjects);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserToken>(entry => {
|
||||
entry.HasKey(e => e.TokenId);
|
||||
entry.Property(e => e.UserId);
|
||||
entry.Property(e => e.ClientIp);
|
||||
entry.Property(e => e.Created);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Project>(entry => {
|
||||
entry.HasKey(e => e.ProjectId);
|
||||
entry.Property(e => e.OwnerId);
|
||||
entry.Property(e => e.Name);
|
||||
entry.Property(e => e.Port);
|
||||
entry.Property(e => e.ContainerName);
|
||||
});
|
||||
}
|
||||
}
|
||||
20
ProjectManager.Backend/Dockerfile
Normal file
20
ProjectManager.Backend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["ProjectManager.Backend.csproj", ""]
|
||||
RUN dotnet restore "ProjectManager.Backend.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src"
|
||||
RUN dotnet build "ProjectManager.Backend.csproj" -c Release -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "ProjectManager.Backend.csproj" -c Release -o /app/publish
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "ProjectManager.Backend.dll"]
|
||||
15
ProjectManager.Backend/Entities/Project.cs
Normal file
15
ProjectManager.Backend/Entities/Project.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace ProjectManager.Backend.Entities;
|
||||
|
||||
public class Project {
|
||||
public string ProjectId { get; set; }
|
||||
public string OwnerId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Port { get; set; }
|
||||
public string ContainerName { get; set; }
|
||||
public int ProxyId { get; set; } = -1;
|
||||
public int CertificateId { get; set; } = -1;
|
||||
}
|
||||
|
||||
public class ProjectEdit {
|
||||
public string Name { get; set; }
|
||||
}
|
||||
9
ProjectManager.Backend/Entities/User.cs
Normal file
9
ProjectManager.Backend/Entities/User.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ProjectManager.Backend.Entities;
|
||||
|
||||
public class User {
|
||||
public string UserId { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string Password { get; set; }
|
||||
public int MaxProjects { get; set; }
|
||||
}
|
||||
8
ProjectManager.Backend/Entities/UserToken.cs
Normal file
8
ProjectManager.Backend/Entities/UserToken.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ProjectManager.Backend.Entities;
|
||||
|
||||
public class UserToken {
|
||||
public string TokenId { get; set; }
|
||||
public string UserId { get; set; }
|
||||
public string ClientIp { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
}
|
||||
13
ProjectManager.Backend/Options/GeneralOptions.cs
Normal file
13
ProjectManager.Backend/Options/GeneralOptions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace ProjectManager.Backend.Options;
|
||||
|
||||
public class GeneralOptions : OptionsFromConfiguration {
|
||||
public override string Position { get; set; } = "General";
|
||||
|
||||
public string Database { get; set; }
|
||||
public int TokenTimeInDays { get; set; }
|
||||
public string PortRange { get; set; }
|
||||
public string DockerPath { get; set; }
|
||||
public string Root { get; set; }
|
||||
public string PocketBaseVersion { get; set; }
|
||||
public int MaxProjects { get; set; }
|
||||
}
|
||||
21
ProjectManager.Backend/Options/OptionsFromConfiguration.cs
Normal file
21
ProjectManager.Backend/Options/OptionsFromConfiguration.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ProjectManager.Backend.Options;
|
||||
|
||||
public abstract class OptionsFromConfiguration {
|
||||
public abstract string Position { get; set; }
|
||||
}
|
||||
|
||||
public static class OptionsFromConfigurationExtensions {
|
||||
|
||||
public static T AddOptionsFromConfiguration<T>(this IServiceCollection services, IConfiguration configuration) where T : OptionsFromConfiguration {
|
||||
T optionsInstance = (T)Activator.CreateInstance(typeof(T));
|
||||
if (optionsInstance == null) return null;
|
||||
var position = optionsInstance.Position;
|
||||
|
||||
services.Configure((Action<T>)(options => {
|
||||
configuration.Bind(position, options);
|
||||
}));
|
||||
|
||||
return optionsInstance;
|
||||
}
|
||||
|
||||
}
|
||||
12
ProjectManager.Backend/Options/ProxyOptions.cs
Normal file
12
ProjectManager.Backend/Options/ProxyOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace ProjectManager.Backend.Options;
|
||||
|
||||
public class ProxyOptions : OptionsFromConfiguration {
|
||||
public override string Position { get; set; } = "Proxy";
|
||||
|
||||
public bool Enable { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Password { get; set; }
|
||||
public string Domain { get; set; }
|
||||
public string Host { get; set; }
|
||||
}
|
||||
49
ProjectManager.Backend/Program.cs
Normal file
49
ProjectManager.Backend/Program.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using ProjectManager.Backend;
|
||||
using ProjectManager.Backend.Options;
|
||||
using ProjectManager.Backend.Security;
|
||||
using ProjectManager.Backend.Apis;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add options to the container
|
||||
builder.Configuration.AddEnvironmentVariables();
|
||||
builder.Services.AddOptionsFromConfiguration<GeneralOptions>(builder.Configuration);
|
||||
builder.Services.AddOptionsFromConfiguration<ProxyOptions>(builder.Configuration);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddDbContext<DatabaseContext>();
|
||||
builder.Services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
builder.Services.AddScoped<ITokenContext, TokenContext>();
|
||||
|
||||
builder.Services.AddScoped<IUserApi, UserApi>();
|
||||
builder.Services.AddScoped<ITokenApi, TokenApi>();
|
||||
builder.Services.AddScoped<IProjectApi, ProjectApi>();
|
||||
builder.Services.AddScoped<IDockerApi, DockerApi>();
|
||||
builder.Services.AddScoped<IProxyApi, ProxyApi>();
|
||||
|
||||
builder.Services.AddCors();
|
||||
builder.Services.AddCustomAuthentication(true);
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment()) {
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors(
|
||||
options => options
|
||||
.WithOrigins(new []{app.Configuration.GetSection("Frontend").Get<string>() ?? ""})
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials()
|
||||
);
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
16
ProjectManager.Backend/ProjectManager.Backend.csproj
Normal file
16
ProjectManager.Backend/ProjectManager.Backend.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Docker.DotNet" Version="3.125.12" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.0" />
|
||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="6.0.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
41
ProjectManager.Backend/Properties/launchSettings.json
Normal file
41
ProjectManager.Backend/Properties/launchSettings.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:47680",
|
||||
"sslPort": 44387
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5110",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7083;http://localhost:5110",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": false,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
ProjectManager.Backend/Security/AuthenticationHandler.cs
Normal file
86
ProjectManager.Backend/Security/AuthenticationHandler.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using ProjectManager.Backend.Apis;
|
||||
|
||||
namespace ProjectManager.Backend.Security;
|
||||
|
||||
public static class AuthenticationExtensions {
|
||||
public static AuthenticationBuilder AddCustomAuthentication(this IServiceCollection services, bool configureSwagger = false) {
|
||||
var builder = services
|
||||
.AddAuthentication("CustomScheme")
|
||||
.AddScheme<AuthenticationSchemeOptions, AuthenticationHandler>("CustomScheme", _ => { });
|
||||
|
||||
if (configureSwagger) {
|
||||
services.AddEndpointsApiExplorer();
|
||||
services.AddSwaggerGen(c => {
|
||||
c.AddSecurityDefinition("CustomScheme", new OpenApiSecurityScheme {
|
||||
Name = "Authorization",
|
||||
In = ParameterLocation.Header,
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Scheme = "CustomScheme"
|
||||
});
|
||||
|
||||
c.AddSecurityRequirement(new OpenApiSecurityRequirement {{
|
||||
new OpenApiSecurityScheme {
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "CustomScheme"
|
||||
},
|
||||
Scheme = "oauth2",
|
||||
Name = "CustomScheme",
|
||||
In = ParameterLocation.Header,
|
||||
},
|
||||
ArraySegment<string>.Empty
|
||||
}});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> {
|
||||
private readonly ITokenApi _tokens;
|
||||
|
||||
public AuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock,
|
||||
ITokenApi tokens)
|
||||
: base(options, logger, encoder, clock) {
|
||||
_tokens = tokens;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
|
||||
string tokenId = Request.Headers["Authorization"];
|
||||
if (string.IsNullOrEmpty(tokenId)) tokenId = Request.Query["token"];
|
||||
string clientIp = Context.Connection.RemoteIpAddress?.ToString();
|
||||
return !_tokens.ValidateToken(tokenId, clientIp) ? AuthenticateResult.Fail("Token invalid") : AuthenticateResult.Success(GenerateTicket(tokenId));
|
||||
}
|
||||
|
||||
private AuthenticationTicket GenerateTicket(string tokenId) {
|
||||
var user = _tokens.GetUserFromToken(tokenId);
|
||||
var claims = new List<Claim> {
|
||||
new("TokenId", tokenId),
|
||||
new("UserId", user.UserId)
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal();
|
||||
principal.AddIdentity(new ClaimsIdentity(claims, Scheme.Name));
|
||||
return new AuthenticationTicket(principal, Scheme.Name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class ClaimsPrincipalExtensions {
|
||||
public static string GetTokenId(this ClaimsPrincipal principal) => principal.FindFirstValue("TokenId");
|
||||
public static string GetUserId(this ClaimsPrincipal principal) => principal.FindFirstValue("UserId");
|
||||
}
|
||||
31
ProjectManager.Backend/Security/AuthorizationHandler.cs
Normal file
31
ProjectManager.Backend/Security/AuthorizationHandler.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ProjectManager.Backend.Security;
|
||||
|
||||
public sealed class AuthorizedAttribute : TypeFilterAttribute {
|
||||
public AuthorizedAttribute(params string[] permission) : base(typeof(AuthorizationHandler)) {
|
||||
Arguments = new object[] { permission };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthorizationHandler : IAuthorizationFilter {
|
||||
|
||||
private readonly string[] _permissions;
|
||||
|
||||
public AuthorizationHandler(params string[] permissions) {
|
||||
_permissions = permissions;
|
||||
}
|
||||
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context) {
|
||||
if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return;
|
||||
if (context.HttpContext.User.Identity?.IsAuthenticated != true) {
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Handle Permissions
|
||||
}
|
||||
}
|
||||
19
ProjectManager.Backend/Security/ITokenContext.cs
Normal file
19
ProjectManager.Backend/Security/ITokenContext.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ProjectManager.Backend.Security;
|
||||
|
||||
public interface ITokenContext {
|
||||
public bool IsAuthenticated { get; }
|
||||
public string TokenId { get; }
|
||||
public string UserId { get; }
|
||||
}
|
||||
|
||||
public sealed class TokenContext : ITokenContext {
|
||||
private readonly IHttpContextAccessor _accessor;
|
||||
|
||||
public bool IsAuthenticated => _accessor.HttpContext?.User.Identity?.IsAuthenticated == true;
|
||||
public string TokenId => _accessor.HttpContext?.User.GetTokenId();
|
||||
public string UserId => _accessor.HttpContext?.User.GetUserId();
|
||||
|
||||
public TokenContext(IHttpContextAccessor accessor) {
|
||||
_accessor = accessor;
|
||||
}
|
||||
}
|
||||
28
ProjectManager.Backend/appsettings.json
Normal file
28
ProjectManager.Backend/appsettings.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"ProjectManager.Backend.Security.AuthenticationHandler": "None"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": ["http://localhost:5110"],
|
||||
"Frontend": null,
|
||||
"General": {
|
||||
"TokenTimeInDays": 30,
|
||||
"Database": null,
|
||||
"PortRange": "24400-24599",
|
||||
"DockerPath": "unix:///var/run/docker.sock",
|
||||
"Root": "./projects/",
|
||||
"PocketBaseVersion": "latest",
|
||||
"MaxProjects": 3
|
||||
},
|
||||
"Proxy": {
|
||||
"Enable": false,
|
||||
"Url": null,
|
||||
"Email": null,
|
||||
"Password": null,
|
||||
"Domain": null,
|
||||
"Host": null
|
||||
}
|
||||
}
|
||||
14
ProjectManager.Frontend/.dockerignore
Normal file
14
ProjectManager.Frontend/.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
# Ignore the node_modules directory
|
||||
node_modules
|
||||
|
||||
# Ignore the dist directory
|
||||
dist
|
||||
|
||||
# Ignore the .git directory
|
||||
.git
|
||||
|
||||
# Ignore the .gitignore file
|
||||
.gitignore
|
||||
|
||||
# Ignore the .dockerignore file
|
||||
.dockerignore
|
||||
16
ProjectManager.Frontend/.editorconfig
Normal file
16
ProjectManager.Frontend/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
ProjectManager.Frontend/.gitignore
vendored
Normal file
42
ProjectManager.Frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
ProjectManager.Frontend/.vscode/extensions.json
vendored
Normal file
4
ProjectManager.Frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
ProjectManager.Frontend/.vscode/launch.json
vendored
Normal file
20
ProjectManager.Frontend/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
ProjectManager.Frontend/.vscode/tasks.json
vendored
Normal file
42
ProjectManager.Frontend/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
ProjectManager.Frontend/Dockerfile
Normal file
11
ProjectManager.Frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
#stage 1
|
||||
FROM node:18 as node
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm install
|
||||
RUN npm run build:ssr --prod
|
||||
#stage 2
|
||||
FROM node:18
|
||||
COPY --from=node /app/dist /app/dist
|
||||
WORKDIR /app
|
||||
CMD ["node", "dist/ProjectManager.Frontend/server/main.js"]
|
||||
160
ProjectManager.Frontend/angular.json
Normal file
160
ProjectManager.Frontend/angular.json
Normal file
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"ProjectManager.Frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/ProjectManager.Frontend/browser",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "10mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "10mb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "ProjectManager.Frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "ProjectManager.Frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "ProjectManager.Frontend:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"builder": "@angular-devkit/build-angular:server",
|
||||
"options": {
|
||||
"outputPath": "dist/ProjectManager.Frontend/server",
|
||||
"main": "server.ts",
|
||||
"tsConfig": "tsconfig.server.json",
|
||||
"inlineStyleLanguage": "scss"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"outputHashing": "media"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"sourceMap": true,
|
||||
"extractLicenses": false,
|
||||
"vendorChunk": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve-ssr": {
|
||||
"builder": "@nguniversal/builders:ssr-dev-server",
|
||||
"configurations": {
|
||||
"development": {
|
||||
"browserTarget": "ProjectManager.Frontend:build:development",
|
||||
"serverTarget": "ProjectManager.Frontend:server:development"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "ProjectManager.Frontend:build:production",
|
||||
"serverTarget": "ProjectManager.Frontend:server:production"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"prerender": {
|
||||
"builder": "@nguniversal/builders:prerender",
|
||||
"options": {
|
||||
"routes": [
|
||||
"/"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "ProjectManager.Frontend:build:production",
|
||||
"serverTarget": "ProjectManager.Frontend:server:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "ProjectManager.Frontend:build:development",
|
||||
"serverTarget": "ProjectManager.Frontend:server:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23715
ProjectManager.Frontend/package-lock.json
generated
Normal file
23715
ProjectManager.Frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
ProjectManager.Frontend/package.json
Normal file
50
ProjectManager.Frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "project-manager.frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"dev:ssr": "ng run ProjectManager.Frontend:serve-ssr",
|
||||
"serve:ssr": "node dist/ProjectManager.Frontend/server/main.js",
|
||||
"build:ssr": "ng build && ng run ProjectManager.Frontend:server",
|
||||
"prerender": "ng run ProjectManager.Frontend:prerender"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^15.0.4",
|
||||
"@angular/cdk": "^15.0.1",
|
||||
"@angular/common": "^15.0.4",
|
||||
"@angular/compiler": "^15.0.4",
|
||||
"@angular/core": "^15.0.4",
|
||||
"@angular/forms": "^15.0.4",
|
||||
"@angular/material": "^15.0.0",
|
||||
"@angular/platform-browser": "^15.0.4",
|
||||
"@angular/platform-browser-dynamic": "^15.0.4",
|
||||
"@angular/platform-server": "^15.0.4",
|
||||
"@angular/router": "^15.0.4",
|
||||
"@nguniversal/express-engine": "^15.0.0",
|
||||
"express": "^4.15.2",
|
||||
"rxjs": "~7.5.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^15.0.4",
|
||||
"@angular/cli": "~15.0.4",
|
||||
"@angular/compiler-cli": "^15.0.4",
|
||||
"@nguniversal/builders": "^15.0.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/jasmine": "~4.3.0",
|
||||
"@types/node": "^14.15.0",
|
||||
"jasmine-core": "~4.5.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.0.0",
|
||||
"typescript": "~4.8.2"
|
||||
}
|
||||
}
|
||||
66
ProjectManager.Frontend/server.ts
Normal file
66
ProjectManager.Frontend/server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'zone.js/node';
|
||||
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
import { ngExpressEngine } from '@nguniversal/express-engine';
|
||||
import * as express from 'express';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { AppServerModule } from './src/main.server';
|
||||
|
||||
// The Express app is exported so that it can be used by serverless Functions.
|
||||
export function app(): express.Express {
|
||||
const server = express();
|
||||
const distFolder = join(process.cwd(), 'dist/ProjectManager.Frontend/browser');
|
||||
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
|
||||
|
||||
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine)
|
||||
server.engine('html', ngExpressEngine({
|
||||
bootstrap: AppServerModule,
|
||||
}));
|
||||
|
||||
server.set('view engine', 'html');
|
||||
server.set('views', distFolder);
|
||||
|
||||
// Example Express Rest API endpoints
|
||||
// server.get('/api/**', (req, res) => { });
|
||||
server.get('/backend', (req, res) => {
|
||||
let backend = process.env['BACKEND']
|
||||
if (!backend?.endsWith("/")) backend += "/";
|
||||
res.json({url: backend});
|
||||
});
|
||||
|
||||
// Serve static files from /browser
|
||||
server.get('*.*', express.static(distFolder, {
|
||||
maxAge: '1y'
|
||||
}));
|
||||
|
||||
// All regular routes use the Universal engine
|
||||
server.get('*', (req, res) => {
|
||||
res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const port = process.env['PORT'] || 4000;
|
||||
|
||||
// Start up the Node server
|
||||
const server = app();
|
||||
server.listen(port, () => {
|
||||
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Webpack will replace 'require' with '__webpack_require__'
|
||||
// '__non_webpack_require__' is a proxy to Node 'require'
|
||||
// The below code is to ensure that the server is run only when not requiring the bundle.
|
||||
declare const __non_webpack_require__: NodeRequire;
|
||||
const mainModule = __non_webpack_require__.main;
|
||||
const moduleFilename = mainModule && mainModule.filename || '';
|
||||
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
|
||||
run();
|
||||
}
|
||||
|
||||
export * from './src/main.server';
|
||||
24
ProjectManager.Frontend/src/app/app-routing.module.ts
Normal file
24
ProjectManager.Frontend/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import {LoginComponent} from "./sites/login/login.component";
|
||||
import {RegisterComponent} from "./sites/register/register.component";
|
||||
import {ProfileComponent} from "./sites/profile/profile.component";
|
||||
import {DashboardComponent} from "./sites/dashboard/dashboard.component";
|
||||
import {ProjectComponent} from "./sites/project/project.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{path: "login", component: LoginComponent},
|
||||
{path: "register", component: RegisterComponent},
|
||||
{path: "dashboard", component: DashboardComponent},
|
||||
{path: "profile", component: ProfileComponent},
|
||||
{path: "project/:id", component: ProjectComponent},
|
||||
{path: "**", pathMatch: "full", redirectTo: "/dashboard"}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes, {
|
||||
initialNavigation: 'enabledBlocking'
|
||||
})],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
5
ProjectManager.Frontend/src/app/app.component.html
Normal file
5
ProjectManager.Frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<app-navigation *ngIf="isLoginRoute() || crud.user != undefined; else loading"></app-navigation>
|
||||
|
||||
<ng-template #loading>
|
||||
<mat-spinner></mat-spinner>
|
||||
</ng-template>
|
||||
0
ProjectManager.Frontend/src/app/app.component.scss
Normal file
0
ProjectManager.Frontend/src/app/app.component.scss
Normal file
41
ProjectManager.Frontend/src/app/app.component.ts
Normal file
41
ProjectManager.Frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {CrudService} from "./services/crud.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {ProjectService} from "./services/project.service";
|
||||
import {StorageService} from "./services/storage.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent{
|
||||
|
||||
public constructor(public crud: CrudService, private router: Router, private projects: ProjectService, private storage: StorageService/* ProjectService gets dependency injected because the onUserUpdate listener needs to be created */) {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.classList.toggle("darkMode", storage.getItem("darkMode") == "true");
|
||||
setTimeout(this.validateToken.bind(this), 0);
|
||||
}
|
||||
|
||||
public isLoginRoute(): boolean {
|
||||
return (this.router.url == '/login' || this.router.url == '/register');
|
||||
}
|
||||
|
||||
private async validateToken() {
|
||||
while (this.crud.backendUrl == undefined) {
|
||||
await this.timeout(200);
|
||||
}
|
||||
|
||||
if (this.isLoginRoute()) return;
|
||||
if (!await this.crud.isAuthenticated())
|
||||
await this.router.navigate(["/login"]);
|
||||
else await this.crud.loadUser();
|
||||
}
|
||||
|
||||
private timeout(ms: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
65
ProjectManager.Frontend/src/app/app.module.ts
Normal file
65
ProjectManager.Frontend/src/app/app.module.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { NavigationComponent } from './components/navigation/navigation.component';
|
||||
import {MatSidenavModule} from "@angular/material/sidenav";
|
||||
import {MatToolbarModule} from "@angular/material/toolbar";
|
||||
import {MatListModule} from "@angular/material/list";
|
||||
import {MatIconModule} from "@angular/material/icon";
|
||||
import {MatButtonModule} from "@angular/material/button";
|
||||
import { LoginComponent } from './sites/login/login.component';
|
||||
import {MatCardModule} from "@angular/material/card";
|
||||
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {MatFormFieldModule} from "@angular/material/form-field";
|
||||
import {MatInputModule} from "@angular/material/input";
|
||||
import {HttpClientModule} from "@angular/common/http";
|
||||
import { RegisterComponent } from './sites/register/register.component';
|
||||
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
|
||||
import {ProfileComponent} from './sites/profile/profile.component';
|
||||
import { DashboardComponent } from './sites/dashboard/dashboard.component';
|
||||
import {MatDialogModule} from "@angular/material/dialog";
|
||||
import { DialogComponent } from './components/dialog/dialog.component';
|
||||
import {MatSnackBarModule} from "@angular/material/snack-bar";
|
||||
import {MatTooltipModule} from "@angular/material/tooltip";
|
||||
import { TextDialogComponent } from './components/text-dialog/text-dialog.component';
|
||||
import { ProjectComponent } from './sites/project/project.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
NavigationComponent,
|
||||
LoginComponent,
|
||||
RegisterComponent,
|
||||
ProfileComponent,
|
||||
DashboardComponent,
|
||||
DialogComponent,
|
||||
TextDialogComponent,
|
||||
ProjectComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule.withServerTransition({ appId: 'serverApp' }),
|
||||
AppRoutingModule,
|
||||
BrowserAnimationsModule,
|
||||
HttpClientModule,
|
||||
MatSidenavModule,
|
||||
MatToolbarModule,
|
||||
MatListModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatTooltipModule
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
14
ProjectManager.Frontend/src/app/app.server.module.ts
Normal file
14
ProjectManager.Frontend/src/app/app.server.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { ServerModule } from '@angular/platform-server';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
ServerModule,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppServerModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
<h1 mat-dialog-title *ngIf="data.title != undefined">{{data.title}}</h1>
|
||||
<div mat-dialog-content *ngIf="data.subtitle != undefined">{{data.subtitle}}</div>
|
||||
<div mat-dialog-actions *ngIf="data.buttons != undefined" id="buttons">
|
||||
<button mat-button
|
||||
(click)="dialogRef.close(button.value)"
|
||||
*ngFor="let button of data.buttons"
|
||||
[color]="button.color"
|
||||
>{{button.text}}</button>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
#buttons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {Component, Inject} from '@angular/core';
|
||||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
||||
import {ThemePalette} from "@angular/material/core";
|
||||
|
||||
export interface DialogData {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
buttons?: {text: string, value: any, color: ThemePalette}[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dialog',
|
||||
templateUrl: './dialog.component.html',
|
||||
styleUrls: ['./dialog.component.scss']
|
||||
})
|
||||
export class DialogComponent {
|
||||
public constructor(
|
||||
public dialogRef: MatDialogRef<DialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: DialogData,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<mat-drawer-container>
|
||||
<mat-drawer mode="side" [opened]="showActions()" id="sidebar" #drawer>
|
||||
<mat-toolbar id="profile">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{crud.user?.username}}</span>
|
||||
</mat-toolbar>
|
||||
|
||||
<mat-selection-list id="actions">
|
||||
<mat-list-item routerLink="/dashboard">
|
||||
<mat-icon matListItemIcon>dashboard</mat-icon>
|
||||
<div matListItemTitle>Übersicht</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="logout()">
|
||||
<mat-icon matListItemIcon>logout</mat-icon>
|
||||
<div matListItemTitle>Ausloggen</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="createProject()">
|
||||
<mat-icon matListItemIcon>add</mat-icon>
|
||||
<div matListItemTitle>Neues Projekt</div>
|
||||
</mat-list-item>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div mat-subheader>Projekte</div>
|
||||
<mat-list-item *ngFor="let project of projects.projects" (click)="openProject(project.projectId)">
|
||||
<mat-icon matListItemIcon>open_in_new</mat-icon>
|
||||
<div matListItemTitle>{{project.name}}</div>
|
||||
<div matListItemLine [ngClass]="{startColor: project.running, stopColor: !project.running}">{{project.running ? 'Läuft' : 'Gestoppt'}}</div>
|
||||
</mat-list-item>
|
||||
</mat-selection-list>
|
||||
|
||||
</mat-drawer>
|
||||
|
||||
<mat-toolbar id="header">
|
||||
<button mat-icon-button (click)="drawer.toggle()" *ngIf="showActions()"><mat-icon>menu</mat-icon></button>
|
||||
<img src="favicon.ico" alt="logo" height="30px" draggable="false">
|
||||
<span>Project Manager</span>
|
||||
|
||||
<section id="top-actions">
|
||||
<button mat-icon-button (click)="onModeChange()" matTooltip="Farbmodus ändern"><mat-icon>{{darkMode ? 'light_mode' : 'dark_mode'}}</mat-icon></button>
|
||||
<button mat-icon-button routerLink="/profile" *ngIf="showActions()" matTooltip="Profil Einstellungen"><mat-icon>account_circle</mat-icon></button>
|
||||
</section>
|
||||
</mat-toolbar>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
<mat-spinner [ngStyle]="{'display': isSpinnerVisible() ? 'block' : 'none'}"></mat-spinner>
|
||||
|
||||
</mat-drawer-container>
|
||||
@@ -0,0 +1,36 @@
|
||||
mat-drawer-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
#sidebar {
|
||||
width: 250px;
|
||||
|
||||
#profile {
|
||||
gap: 20px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#actions {
|
||||
height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#header {
|
||||
gap: 10px;
|
||||
padding-left: 5px;
|
||||
|
||||
#top-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {Router} from "@angular/router";
|
||||
import {CrudService} from "../../services/crud.service";
|
||||
import {ProjectService} from "../../services/project.service";
|
||||
import {MatDialog} from "@angular/material/dialog";
|
||||
import {TextDialogComponent} from "../text-dialog/text-dialog.component";
|
||||
import {firstValueFrom} from "rxjs";
|
||||
import {MatSnackBar} from "@angular/material/snack-bar";
|
||||
import {StorageService} from "../../services/storage.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrls: ['./navigation.component.scss']
|
||||
})
|
||||
export class NavigationComponent {
|
||||
public static spinnerVisible: boolean = false;
|
||||
public darkMode: boolean;
|
||||
|
||||
public constructor(public router: Router, public crud: CrudService, public projects: ProjectService, public dialog: MatDialog, private snackBar: MatSnackBar, private storage: StorageService) {
|
||||
this.darkMode = storage.getItem("darkMode") == "true";
|
||||
}
|
||||
|
||||
public isSpinnerVisible(): boolean {
|
||||
return NavigationComponent.spinnerVisible;
|
||||
}
|
||||
|
||||
public onModeChange(): void {
|
||||
this.darkMode = !document.body.classList.contains("darkMode");
|
||||
document.body.classList.toggle("darkMode", this.darkMode);
|
||||
this.storage.setItem("darkMode", JSON.stringify(this.darkMode));
|
||||
}
|
||||
|
||||
public showActions(): boolean {
|
||||
return this.router.url != '/' && this.router.url != '/login' && this.router.url != '/register';
|
||||
}
|
||||
|
||||
public async logout() {
|
||||
this.crud.setAuthKey(undefined);
|
||||
this.crud.user = undefined;
|
||||
await this.router.navigate(["login"]);
|
||||
}
|
||||
|
||||
public async createProject() {
|
||||
const dialogRef = this.dialog.open(TextDialogComponent, {
|
||||
data: {title: "Neues Projekt", subtitle: "Name", buttons: [
|
||||
{text: "Abbrechen", value: false},
|
||||
{text: "Projekt erstellen", value: true, color: 'primary'}
|
||||
]}
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.afterClosed()) as {success: boolean, data: string};
|
||||
if (!result?.success) return;
|
||||
NavigationComponent.spinnerVisible = true;
|
||||
const projectId = await this.projects.addProject(result.data);
|
||||
NavigationComponent.spinnerVisible = false;
|
||||
if (projectId == undefined) {
|
||||
this.snackBar.open("Projekt kann nicht erstellt werden!", undefined, {duration: 2000});
|
||||
return;
|
||||
}
|
||||
await this.projects.loadProjects();
|
||||
this.snackBar.open("Projekt erstellt!", undefined, {duration: 2000});
|
||||
}
|
||||
|
||||
public openProject(projectId: string) {
|
||||
window.open(`${this.crud.backendUrl}projects/${projectId}/url?token=${this.crud.authKey}`, '_blank').focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<h1 mat-dialog-title *ngIf="data.title != undefined">{{data.title}}</h1>
|
||||
<form mat-dialog-content (submit)="$event.preventDefault(); dialogRef.close({success: true, data: text.value})">
|
||||
<mat-form-field>
|
||||
<mat-label>{{data.subtitle}}</mat-label>
|
||||
<input type="text" matInput #text>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
<div mat-dialog-actions *ngIf="data.buttons != undefined" id="buttons">
|
||||
<button mat-button
|
||||
(click)="dialogRef.close({success: button.value, data: text.value})"
|
||||
*ngFor="let button of data.buttons"
|
||||
[color]="button.color"
|
||||
>{{button.text}}</button>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
import {Component, Inject} from '@angular/core';
|
||||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
||||
import {DialogData} from "../dialog/dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-text-dialog',
|
||||
templateUrl: './text-dialog.component.html',
|
||||
styleUrls: ['./text-dialog.component.scss']
|
||||
})
|
||||
export class TextDialogComponent {
|
||||
public constructor(
|
||||
public dialogRef: MatDialogRef<TextDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: DialogData,
|
||||
) {}
|
||||
}
|
||||
10
ProjectManager.Frontend/src/app/entities/project.ts
Normal file
10
ProjectManager.Frontend/src/app/entities/project.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Project {
|
||||
projectId?: string;
|
||||
ownerId?: string;
|
||||
name?: string;
|
||||
port?: number;
|
||||
containerName?: string;
|
||||
proxyId?: number;
|
||||
certificateId?: number;
|
||||
running?: boolean;
|
||||
}
|
||||
6
ProjectManager.Frontend/src/app/entities/user.ts
Normal file
6
ProjectManager.Frontend/src/app/entities/user.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface User {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
120
ProjectManager.Frontend/src/app/services/crud.service.ts
Normal file
120
ProjectManager.Frontend/src/app/services/crud.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {HttpClient, HttpErrorResponse, HttpHeaders} from "@angular/common/http";
|
||||
import {firstValueFrom} from "rxjs";
|
||||
import {User} from "../entities/user";
|
||||
import {StorageService} from "./storage.service";
|
||||
|
||||
let backend: string;
|
||||
|
||||
export interface BackendResponse<T> {
|
||||
content: T;
|
||||
success: boolean;
|
||||
code: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CrudService {
|
||||
public user: User;
|
||||
public onUserUpdate: (() => void)[] = [];
|
||||
public authKey: string;
|
||||
private headers: HttpHeaders = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': ''
|
||||
});
|
||||
|
||||
constructor(private client: HttpClient, private storage: StorageService) {
|
||||
this.getBackendUrl().then(() => {
|
||||
this.authKey = storage.getItem("api_key");
|
||||
this.setAuthKey(this.authKey);
|
||||
})
|
||||
}
|
||||
|
||||
private async getBackendUrl() {
|
||||
backend = (await firstValueFrom(this.client.get<{url: string}>(location?.origin + "/backend"))).url;
|
||||
}
|
||||
|
||||
public setAuthKey(key: string): void {
|
||||
this.authKey = key;
|
||||
this.headers = this.headers.set("Authorization", key || '');
|
||||
this.storage.setItem("api_key", key);
|
||||
}
|
||||
|
||||
public async isAuthenticated(): Promise<boolean> {
|
||||
if (this.authKey == undefined) return false;
|
||||
const result = await this.sendGetRequest("users/token");
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async loadUser(forceLoad: boolean = false): Promise<User> {
|
||||
if (this.authKey == undefined) return undefined;
|
||||
if (this.user != undefined && !forceLoad) return this.user;
|
||||
this.onUserUpdate.forEach(update => update.call(this));
|
||||
const response = await this.sendGetRequest<User>("users/me");
|
||||
this.user = response.content;
|
||||
return this.user;
|
||||
}
|
||||
|
||||
public get backendUrl(): string {
|
||||
return backend;
|
||||
}
|
||||
|
||||
public async sendGetRequest<T>(endpoint: string): Promise<BackendResponse<T>> {
|
||||
try {
|
||||
const result = await firstValueFrom(this.client.get<T>(backend + endpoint, {headers: this.headers}));
|
||||
return {content: result, success: true, code: 200};
|
||||
} catch (e) {
|
||||
const error = e as HttpErrorResponse;
|
||||
|
||||
if (error.status == 0)
|
||||
return {content: undefined, success: false, code: error.status, message: "Server nicht erreichbar!"};
|
||||
|
||||
return {content: undefined, success: false, code: error.status, message: error.error};
|
||||
}
|
||||
}
|
||||
|
||||
public async sendPutRequest<T>(endpoint: string, body?: any): Promise<BackendResponse<T>> {
|
||||
try {
|
||||
const result = await firstValueFrom(this.client.put<T>(backend + endpoint, body, {headers: this.headers}));
|
||||
return {content: result, success: true, code: 200};
|
||||
} catch (e) {
|
||||
const error = e as HttpErrorResponse;
|
||||
|
||||
if (error.status == 0)
|
||||
return {content: undefined, success: false, code: error.status, message: "Server nicht erreichbar!"};
|
||||
|
||||
return {content: undefined, success: false, code: error.status, message: error.error};
|
||||
}
|
||||
}
|
||||
|
||||
public async sendPostRequest<T>(endpoint: string, body?: any): Promise<BackendResponse<T>> {
|
||||
try {
|
||||
const result = await firstValueFrom(this.client.post<T>(backend + endpoint, body, {headers: this.headers}));
|
||||
return {content: result, success: true, code: 200};
|
||||
} catch (e) {
|
||||
const error = e as HttpErrorResponse;
|
||||
|
||||
if (error.status == 0)
|
||||
return {content: undefined, success: false, code: error.status, message: "Server nicht erreichbar!"};
|
||||
|
||||
return {content: undefined, success: false, code: error.status, message: error.error};
|
||||
}
|
||||
}
|
||||
|
||||
public async sendDeleteRequest<T>(endpoint: string): Promise<BackendResponse<T>> {
|
||||
try {
|
||||
const result = await firstValueFrom(this.client.delete<T>(backend + endpoint, {headers: this.headers}));
|
||||
return {content: result, success: true, code: 200};
|
||||
} catch (e) {
|
||||
const error = e as HttpErrorResponse;
|
||||
|
||||
if (error.status == 0)
|
||||
return {content: undefined, success: false, code: error.status, message: "Server nicht erreichbar!"};
|
||||
|
||||
return {content: undefined, success: false, code: error.status, message: error.error};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
58
ProjectManager.Frontend/src/app/services/project.service.ts
Normal file
58
ProjectManager.Frontend/src/app/services/project.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {CrudService} from "./crud.service";
|
||||
import {Project} from "../entities/project";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ProjectService {
|
||||
public projects: Project[] = []
|
||||
|
||||
constructor(private crud: CrudService) {
|
||||
crud.onUserUpdate.push(this.loadProjects.bind(this));
|
||||
}
|
||||
|
||||
public async loadProjects() {
|
||||
this.projects = [];
|
||||
const result = (await this.crud.sendGetRequest<{projects: Project[], running: boolean[]}>("projects")).content;
|
||||
for (let i = 0; i < result.projects.length; i++) {
|
||||
this.projects[i] = result.projects[i];
|
||||
this.projects[i].running = result.running[i];
|
||||
}
|
||||
}
|
||||
|
||||
public async getProject(projectId: string): Promise<Project> {
|
||||
const response = await this.crud.sendGetRequest<Project>("projects/" + projectId);
|
||||
return response.content;
|
||||
}
|
||||
|
||||
public async addProject(name: string): Promise<string> {
|
||||
const response = await this.crud.sendPostRequest<{projectId: string}>("projects", {name});
|
||||
return response.content?.projectId;
|
||||
}
|
||||
|
||||
public async editProject(projectId: string, name: string): Promise<boolean> {
|
||||
const response = await this.crud.sendPutRequest("projects/" + projectId, {name});
|
||||
return response.success;
|
||||
}
|
||||
|
||||
public async deleteProject(projectId: string): Promise<boolean> {
|
||||
const response = await this.crud.sendDeleteRequest("projects/" + projectId);
|
||||
return response.success;
|
||||
}
|
||||
|
||||
public async startProject(projectId: string): Promise<boolean> {
|
||||
const response = await this.crud.sendGetRequest("projects/" + projectId + "/start");
|
||||
return response.success;
|
||||
}
|
||||
|
||||
public async stopProject(projectId: string): Promise<boolean> {
|
||||
const response = await this.crud.sendGetRequest("projects/" + projectId + "/stop");
|
||||
return response.success;
|
||||
}
|
||||
|
||||
public async isProjectRunning(projectId: string): Promise<boolean> {
|
||||
const response = await this.crud.sendGetRequest<{started: boolean}>("projects/" + projectId + "/status");
|
||||
return response.content?.started;
|
||||
}
|
||||
}
|
||||
16
ProjectManager.Frontend/src/app/services/storage.service.ts
Normal file
16
ProjectManager.Frontend/src/app/services/storage.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StorageService {
|
||||
public setItem(setItem,data){
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(setItem, data);
|
||||
}
|
||||
|
||||
public getItem(getItem){
|
||||
if (typeof localStorage === "undefined") return undefined;
|
||||
return localStorage.getItem(getItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<section id="main">
|
||||
<h1 id="welcome">Willkommen {{crud.user.username}}</h1>
|
||||
|
||||
<h2 id="title">Projekte</h2>
|
||||
<div id="projects">
|
||||
<span *ngIf="projects.projects.length == 0 && crud.user != undefined" class="disabled">Du hast noch keine Projekte erstellt</span>
|
||||
<mat-card *ngFor="let project of projects.projects" class="project">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{project.name}}</mat-card-title>
|
||||
<mat-card-subtitle>{{project.projectId}}</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-actions>
|
||||
<button mat-button color="primary" (click)="router.navigate(['/project', project.projectId])">Öffnen</button>
|
||||
<button mat-button color="accent" (click)="editProject(project.projectId)">Bearbeiten</button>
|
||||
<button mat-button color="warn" (click)="deleteProject(project.projectId)">Löschen</button>
|
||||
|
||||
<button mat-icon-button color="warn" *ngIf="project.running" (click)="updateProjectStatus(project.projectId, false)"><mat-icon>pause</mat-icon></button>
|
||||
<button mat-icon-button color="accent" *ngIf="!project.running" (click)="updateProjectStatus(project.projectId, true)"><mat-icon>play_arrow</mat-icon></button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,46 @@
|
||||
#main {
|
||||
height: calc(100vh - 125px);
|
||||
margin: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
#welcome {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
#projects {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
flex-wrap: wrap;
|
||||
overflow-y: auto;
|
||||
|
||||
.project {
|
||||
width: 350px;
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
animation: 200ms project ease-out forwards;
|
||||
|
||||
mat-card-actions {
|
||||
margin-top: auto;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through 100 {
|
||||
.project:nth-child(#{$i}n) {
|
||||
animation-delay: #{$i * 0.1}s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes project {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {ProjectService} from "../../services/project.service";
|
||||
import {CrudService} from "../../services/crud.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {MatDialog} from "@angular/material/dialog";
|
||||
import {DialogComponent} from "../../components/dialog/dialog.component";
|
||||
import {firstValueFrom} from "rxjs";
|
||||
import {MatSnackBar} from "@angular/material/snack-bar";
|
||||
import {TextDialogComponent} from "../../components/text-dialog/text-dialog.component";
|
||||
import {NavigationComponent} from "../../components/navigation/navigation.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.scss']
|
||||
})
|
||||
export class DashboardComponent {
|
||||
|
||||
public constructor(public crud: CrudService, public projects: ProjectService, public router: Router, private dialog: MatDialog, private snackBar: MatSnackBar) {}
|
||||
|
||||
public async editProject(projectId: string) {
|
||||
const dialogRef = this.dialog.open(TextDialogComponent, {
|
||||
data: {title: "Projekt umbenennen", subtitle: "Name", buttons: [
|
||||
{text: "Abbrechen", value: false},
|
||||
{text: "Projekt bearbeiten", value: true, color: 'primary'}
|
||||
]}
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.afterClosed()) as {success: boolean, data: string};
|
||||
if (!result?.success) return;
|
||||
await this.projects.editProject(projectId, result.data);
|
||||
await this.projects.loadProjects();
|
||||
this.snackBar.open("Projekt aktualisiert!", undefined, {duration: 2000});
|
||||
}
|
||||
|
||||
public async deleteProject(projectId: string) {
|
||||
const dialogRef = this.dialog.open(DialogComponent, {
|
||||
data: {title: "Möchtest du das Projekt wirklich löschen?", subtitle: "Alle gespeicherten Daten gehen dann verloren!", buttons: [
|
||||
{text: "Abbrechen", value: false},
|
||||
{text: "Löschen", value: true, color: 'warn'}
|
||||
]}
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.afterClosed());
|
||||
if (!result) return;
|
||||
NavigationComponent.spinnerVisible = true;
|
||||
await this.projects.deleteProject(projectId);
|
||||
NavigationComponent.spinnerVisible = false;
|
||||
await this.projects.loadProjects();
|
||||
this.snackBar.open("Projekt gelöscht!", undefined, {duration: 2000});
|
||||
}
|
||||
|
||||
public async updateProjectStatus(projectId: string, start: boolean) {
|
||||
if (start) await this.projects.startProject(projectId);
|
||||
else await this.projects.stopProject(projectId);
|
||||
await this.projects.loadProjects();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<mat-card>
|
||||
<mat-card-title>Einloggen</mat-card-title>
|
||||
<mat-divider></mat-divider>
|
||||
<mat-card-content>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-form-field>
|
||||
<mat-label>E-Mail</mat-label>
|
||||
<input type="text" matInput formControlName="email" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'email')">E-Mail ist erforderlich</mat-error>
|
||||
<mat-error *ngIf="form.hasError('email', 'email') && !form.hasError('required', 'email')">
|
||||
Bitte geben Sie eine gültige E-Mail-Adresse ein
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Passwort</mat-label>
|
||||
<input type="password" matInput formControlName="password" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'password')">Passwort ist erforderlich</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
<span>Du besitzt keinen Account? <a routerLink="/register">Registrieren</a></span>
|
||||
|
||||
<mat-error *ngIf="error">{{error}}</mat-error>
|
||||
|
||||
<button type="submit" mat-button>Einloggen</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,35 @@
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 300px;
|
||||
}
|
||||
|
||||
mat-card {
|
||||
user-select: none;
|
||||
width: 500px;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
font-size: 30px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 20px;
|
||||
gap: 20px;
|
||||
|
||||
button {
|
||||
width: min-content;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: unset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {FormControl, FormGroup, Validators} from "@angular/forms";
|
||||
import {CrudService} from "../../services/crud.service";
|
||||
import {User} from "../../entities/user";
|
||||
import {Router} from "@angular/router";
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.scss']
|
||||
})
|
||||
export class LoginComponent {
|
||||
public form: FormGroup = new FormGroup({
|
||||
email: new FormControl('', [Validators.email]),
|
||||
password: new FormControl(''),
|
||||
});
|
||||
public error: string;
|
||||
|
||||
public constructor(private crud: CrudService, private router: Router) {
|
||||
this.form.reset();
|
||||
this.error = "";
|
||||
}
|
||||
|
||||
public async submit() {
|
||||
this.error = "";
|
||||
const email = this.form.get("email").value;
|
||||
const password = this.form.get("password").value;
|
||||
const user: User = {email: email, password: password};
|
||||
|
||||
const result = await this.crud.sendPostRequest<{token: string}>("users/login", user);
|
||||
if (result.success) {
|
||||
this.crud.setAuthKey(result.content.token);
|
||||
await this.crud.loadUser(true);
|
||||
await this.router.navigate(["/dashboard"]);
|
||||
}else {
|
||||
this.error = "E-Mail oder Passwort ist falsch";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<mat-card id="content">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Profil</mat-card-title>
|
||||
<mat-card-subtitle>Einstellungen</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content id="main">
|
||||
<form [formGroup]="form" (ngSubmit)="update()" id="form">
|
||||
<mat-form-field>
|
||||
<mat-label>E-Mail</mat-label>
|
||||
<input type="text" matInput formControlName="email" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'email')">E-Mail ist erforderlich</mat-error>
|
||||
<mat-error *ngIf="form.hasError('email', 'email') && !form.hasError('required', 'email')">
|
||||
Bitte geben Sie eine gültige E-Mail-Adresse ein
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'email') && !form.hasError('required', 'email')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Benutzername</mat-label>
|
||||
<input type="text" matInput formControlName="username" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'username')">Benutzername ist erforderlich</mat-error>
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'username') && !form.hasError('required', 'username')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<mat-label>Passwort</mat-label>
|
||||
<input type="password" matInput formControlName="password">
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'password')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Passwort wiederholen</mat-label>
|
||||
<input type="password" matInput formControlName="passwordRepeat">
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'passwordRepeat')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-error>{{error}}</mat-error>
|
||||
<button type="submit" hidden></button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions id="actions">
|
||||
<button mat-button color="primary" (click)="update()">Account aktualisieren</button>
|
||||
<button mat-button color="warn" (click)="delete()">Account löschen</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,30 @@
|
||||
:host {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: calc(100% - 64px);
|
||||
}
|
||||
|
||||
#content {
|
||||
width: 600px;
|
||||
|
||||
#main {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
#form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#actions {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {CrudService} from "../../services/crud.service";
|
||||
import {FormControl, FormGroup, Validators} from "@angular/forms";
|
||||
import {MatDialog} from "@angular/material/dialog";
|
||||
import {DialogComponent} from "../../components/dialog/dialog.component";
|
||||
import {firstValueFrom} from "rxjs";
|
||||
import {User} from "../../entities/user";
|
||||
import {MatSnackBar} from "@angular/material/snack-bar";
|
||||
import {Router} from "@angular/router";
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
templateUrl: './profile.component.html',
|
||||
styleUrls: ['./profile.component.scss']
|
||||
})
|
||||
export class ProfileComponent {
|
||||
public form: FormGroup = new FormGroup({
|
||||
email: new FormControl('', [Validators.email, Validators.maxLength(255)]),
|
||||
username: new FormControl('', [Validators.maxLength(255)]),
|
||||
password: new FormControl('', [Validators.maxLength(255)]),
|
||||
passwordRepeat: new FormControl('', [Validators.maxLength(255)])
|
||||
});
|
||||
public error: string;
|
||||
|
||||
public constructor(public crud: CrudService, private router: Router, public dialog: MatDialog, private snackBar: MatSnackBar) {
|
||||
this.form.get("email").setValue(this.crud.user?.email);
|
||||
this.form.get("username").setValue(this.crud.user?.username);
|
||||
}
|
||||
|
||||
public async update() {
|
||||
if (!this.form.valid) return;
|
||||
const result = await this.openDialog("Änderungen speichern?");
|
||||
if (!result) return;
|
||||
|
||||
this.error = "";
|
||||
const email = this.form.get("email").value;
|
||||
const username = this.form.get("username").value;
|
||||
const password = this.form.get("password").value;
|
||||
const passwordRepeat = this.form.get("passwordRepeat").value;
|
||||
|
||||
if (password != passwordRepeat) {
|
||||
this.error = "Passwörter stimmen nicht überein";
|
||||
return;
|
||||
}
|
||||
|
||||
const user: User = {userId: this.crud.user.userId, email, username, password};
|
||||
|
||||
const response = await this.crud.sendPutRequest("users", user);
|
||||
if (!response.success) {
|
||||
this.error = "Aktualiserung fehlgeschlagen!";
|
||||
return;
|
||||
}
|
||||
|
||||
await this.crud.loadUser(true);
|
||||
this.form.reset();
|
||||
this.snackBar.open("Account aktualisiert!", undefined, {duration: 2000});
|
||||
await this.router.navigate(["dashboard"]);
|
||||
}
|
||||
|
||||
public async delete() {
|
||||
const result = await this.openDialog("Möchtest du deinen Account wirklich löschen?", "All deine Projekte werden für immer gelöscht!", ['', 'warn']);
|
||||
if (!result) return;
|
||||
|
||||
await this.crud.sendDeleteRequest("users");
|
||||
|
||||
this.crud.setAuthKey(undefined);
|
||||
this.crud.user = undefined;
|
||||
this.snackBar.open("Account gelöscht!", undefined, {duration: 2000});
|
||||
await this.router.navigate(["login"]);
|
||||
}
|
||||
|
||||
private openDialog(title: string, subtitle?: string, colors?: string[]): Promise<boolean> {
|
||||
if (colors == undefined) colors = ['', 'accent'];
|
||||
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
const dialogRef = this.dialog.open(DialogComponent, {
|
||||
data: {title, subtitle, buttons: [
|
||||
{text: "Abbrechen", value: false, color: colors[0]},
|
||||
{text: "Bestätigen", value: true, color: colors[1]},
|
||||
]}
|
||||
});
|
||||
|
||||
resolve(await firstValueFrom(dialogRef.afterClosed()));
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<iframe #frame></iframe>
|
||||
@@ -0,0 +1,10 @@
|
||||
:host {
|
||||
display: flex;
|
||||
height: calc(100% - 64px);
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {Component, ElementRef, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute} from "@angular/router";
|
||||
import {CrudService} from "../../services/crud.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-project',
|
||||
templateUrl: './project.component.html',
|
||||
styleUrls: ['./project.component.scss']
|
||||
})
|
||||
export class ProjectComponent {
|
||||
@ViewChild('frame') frame: ElementRef;
|
||||
|
||||
public constructor(public route: ActivatedRoute, public crud: CrudService) {
|
||||
setTimeout(this.getRoute.bind(this), 0);
|
||||
}
|
||||
|
||||
public getRoute() {
|
||||
this.route.params.subscribe(params => {
|
||||
this.frame.nativeElement.src = this.crud.backendUrl + 'projects/' + params['id'] + '/url?token=' + this.crud.authKey;
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<mat-card>
|
||||
<mat-card-title>Registrieren</mat-card-title>
|
||||
<mat-divider></mat-divider>
|
||||
<mat-card-content>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-form-field>
|
||||
<mat-label>E-Mail</mat-label>
|
||||
<input type="text" matInput formControlName="email" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'email')">E-Mail ist erforderlich</mat-error>
|
||||
<mat-error *ngIf="form.hasError('email', 'email') && !form.hasError('required', 'email')">
|
||||
Bitte geben Sie eine gültige E-Mail-Adresse ein
|
||||
</mat-error>
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'email') && !form.hasError('required', 'email')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Benutzername</mat-label>
|
||||
<input type="text" matInput formControlName="username" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'username')">Benutzername ist erforderlich</mat-error>
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'username') && !form.hasError('required', 'username')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Passwort</mat-label>
|
||||
<input type="password" matInput formControlName="password" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'password')">Passwort ist erforderlich</mat-error>
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'password') && !form.hasError('required', 'password')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Passwort wiederholen</mat-label>
|
||||
<input type="password" matInput formControlName="passwordRepeat" required>
|
||||
<mat-error *ngIf="form.hasError('required', 'passwordRepeat')">Passwort ist erforderlich</mat-error>
|
||||
<mat-error *ngIf="form.hasError('maxlength', 'passwordRepeat') && !form.hasError('required', 'passwordRepeat')">
|
||||
Der eingegebene Wert ist zu lang
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
<span>Du hast bereits einen Account? <a routerLink="/login">Einloggen</a></span>
|
||||
|
||||
<mat-error *ngIf="error">{{error}}</mat-error>
|
||||
|
||||
<button type="submit" mat-button>Registrieren</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,35 @@
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 300px;
|
||||
}
|
||||
|
||||
mat-card {
|
||||
user-select: none;
|
||||
width: 500px;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
font-size: 30px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 20px;
|
||||
gap: 20px;
|
||||
|
||||
button {
|
||||
width: min-content;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: unset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {FormControl, FormGroup, Validators} from "@angular/forms";
|
||||
import {CrudService} from "../../services/crud.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {User} from "../../entities/user";
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
templateUrl: './register.component.html',
|
||||
styleUrls: ['./register.component.scss']
|
||||
})
|
||||
export class RegisterComponent {
|
||||
public form: FormGroup = new FormGroup({
|
||||
email: new FormControl('', [Validators.email, Validators.maxLength(255)]),
|
||||
username: new FormControl('', [Validators.maxLength(255)]),
|
||||
password: new FormControl('', [Validators.maxLength(255)]),
|
||||
passwordRepeat: new FormControl('', [Validators.maxLength(255)])
|
||||
});
|
||||
public error: string;
|
||||
|
||||
public constructor(private crud: CrudService, private router: Router) {}
|
||||
|
||||
public async submit() {
|
||||
this.error = "";
|
||||
const email = this.form.get("email").value;
|
||||
const username = this.form.get("username").value;
|
||||
const password = this.form.get("password").value;
|
||||
const passwordRepeat = this.form.get("passwordRepeat").value;
|
||||
|
||||
if (password != passwordRepeat) {
|
||||
this.error = "Passwörter stimmen nicht überein";
|
||||
return;
|
||||
}
|
||||
|
||||
const user: User = {email, username, password};
|
||||
const result = await this.crud.sendPostRequest<{token: string}>("users/register", user);
|
||||
if (!result.success) {
|
||||
this.error = "Registrierung fehlgeschlagen";
|
||||
return;
|
||||
}
|
||||
|
||||
this.crud.setAuthKey(result.content.token);
|
||||
await this.crud.loadUser(true);
|
||||
await this.router.navigate(["/dashboard"]);
|
||||
}
|
||||
}
|
||||
0
ProjectManager.Frontend/src/assets/.gitkeep
Normal file
0
ProjectManager.Frontend/src/assets/.gitkeep
Normal file
BIN
ProjectManager.Frontend/src/favicon.ico
Normal file
BIN
ProjectManager.Frontend/src/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
16
ProjectManager.Frontend/src/index.html
Normal file
16
ProjectManager.Frontend/src/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Project Manager</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body class="mat-typography darkMode">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
2
ProjectManager.Frontend/src/main.server.ts
Normal file
2
ProjectManager.Frontend/src/main.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export { AppServerModule } from './app/app.server.module';
|
||||
17
ProjectManager.Frontend/src/main.ts
Normal file
17
ProjectManager.Frontend/src/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
|
||||
|
||||
function bootstrap() {
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err));
|
||||
};
|
||||
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
bootstrap();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
}
|
||||
|
||||
115
ProjectManager.Frontend/src/styles.scss
Normal file
115
ProjectManager.Frontend/src/styles.scss
Normal file
@@ -0,0 +1,115 @@
|
||||
@use 'sass:map';
|
||||
@use '/node_modules/@angular/material' as mat;
|
||||
@import "/node_modules/@angular/material/theming";
|
||||
@include mat.core();
|
||||
|
||||
$angular-primary: mat.define-palette(mat.$blue-palette, 500, 100, 900);
|
||||
$angular-accent: mat.define-palette(mat.$green-palette, A200, A100, A400);
|
||||
$angular-warn: mat.define-palette(mat.$red-palette);
|
||||
|
||||
$angular-default-theme: mat.define-light-theme(
|
||||
(
|
||||
color: (
|
||||
primary: $angular-primary,
|
||||
accent: $angular-accent,
|
||||
warn: $angular-warn,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$angular-dark-theme: mat.define-dark-theme(
|
||||
(
|
||||
color: (
|
||||
primary: $angular-primary,
|
||||
accent: $angular-accent,
|
||||
warn: $angular-warn,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
.darkMode {
|
||||
@include mat.all-component-colors($angular-dark-theme);
|
||||
|
||||
$color-config: mat.get-color-config($angular-dark-theme);
|
||||
$background: map.get($color-config, 'background');
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: map.get($background, 'app-bar');
|
||||
border-radius: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: map.get($background, 'hover');
|
||||
border-radius: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: map.get($background, 'focused-button');
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: map.get($background, 'background');
|
||||
}
|
||||
|
||||
.stopColor {
|
||||
$stopColor: map.get($color-config, 'warn');
|
||||
color: map.get($stopColor, 500);
|
||||
}
|
||||
|
||||
.startColor {
|
||||
$startColor: map.get($color-config, 'accent');
|
||||
color: map.get($startColor, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@include mat.all-component-themes($angular-default-theme);
|
||||
|
||||
$color-config: mat.get-color-config($angular-default-theme);
|
||||
$background: map.get($color-config, 'background');
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: map.get($background, 'app-bar');
|
||||
border-radius: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: map.get($background, 'hover');
|
||||
border-radius: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: map.get($background, 'focused-button');
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stopColor {
|
||||
$stopColor: map.get($color-config, 'warn');
|
||||
color: map.get($stopColor, 500);
|
||||
}
|
||||
|
||||
.startColor {
|
||||
$startColor: map.get($color-config, 'accent');
|
||||
color: map.get($startColor, 500);
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Roboto, "Helvetica Neue", sans-serif;
|
||||
background-color: map.get($background, 'background');
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
14
ProjectManager.Frontend/tsconfig.app.json
Normal file
14
ProjectManager.Frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
32
ProjectManager.Frontend/tsconfig.json
Normal file
32
ProjectManager.Frontend/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom"
|
||||
]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
14
ProjectManager.Frontend/tsconfig.server.json
Normal file
14
ProjectManager.Frontend/tsconfig.server.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/server",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"src/main.server.ts",
|
||||
"server.ts"
|
||||
]
|
||||
}
|
||||
14
ProjectManager.Frontend/tsconfig.spec.json
Normal file
14
ProjectManager.Frontend/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
16
ProjectManager.sln
Normal file
16
ProjectManager.sln
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectManager.Backend", "ProjectManager.Backend\ProjectManager.Backend.csproj", "{2C101F89-B06E-453A-A107-C20C6B1766E6}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{2C101F89-B06E-453A-A107-C20C6B1766E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2C101F89-B06E-453A-A107-C20C6B1766E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2C101F89-B06E-453A-A107-C20C6B1766E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2C101F89-B06E-453A-A107-C20C6B1766E6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
34
ProjectManager.sql
Normal file
34
ProjectManager.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE `Projects` (
|
||||
`projectId` varchar(36) NOT NULL,
|
||||
`ownerId` varchar(36) DEFAULT NULL,
|
||||
`name` varchar(255) DEFAULT NULL,
|
||||
`port` int(5) DEFAULT NULL,
|
||||
`containerName` varchar(255) DEFAULT NULL,
|
||||
`proxyId` int(30) DEFAULT NULL,
|
||||
`certificateId` int(30) DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE `Tokens` (
|
||||
`tokenId` varchar(36) NOT NULL,
|
||||
`userId` varchar(36) DEFAULT NULL,
|
||||
`clientIp` varchar(255) DEFAULT NULL,
|
||||
`created` datetime DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE `Users` (
|
||||
`userId` varchar(36) NOT NULL,
|
||||
`email` varchar(255) DEFAULT NULL,
|
||||
`username` varchar(255) DEFAULT NULL,
|
||||
`password` varchar(255) DEFAULT NULL,
|
||||
`maxProjects` int(3) DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
ALTER TABLE `Projects`
|
||||
ADD PRIMARY KEY (`projectId`);
|
||||
|
||||
ALTER TABLE `Tokens`
|
||||
ADD PRIMARY KEY (`tokenId`);
|
||||
|
||||
ALTER TABLE `Users`
|
||||
ADD PRIMARY KEY (`userId`);
|
||||
COMMIT;
|
||||
27
docker-compose.example.yml
Normal file
27
docker-compose.example.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
version: '3.0'
|
||||
services:
|
||||
frontend:
|
||||
build: ProjectManager.Frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- BACKEND=https://api.example.com
|
||||
ports:
|
||||
- '4220:4000'
|
||||
|
||||
backend:
|
||||
build: ProjectManager.Backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FRONTEND=https://example.com
|
||||
- GENERAL__DATABASE=server=1.2.3.4;database=ProjectManager;user=ProjectManager;password=changeMe
|
||||
- GENERAL__ROOT=/projects
|
||||
- PROXY__ENABLE=true
|
||||
- PROXY__URL=https://proxy.example.com
|
||||
- PROXY__EMAIL=admin@example.com
|
||||
- PROXY__PASSWORD=changeMe
|
||||
- PROXY__DOMAIN=api.example.com
|
||||
- PROXY__HOST=1.2.3.4
|
||||
ports:
|
||||
- '4221:80'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
7
global.json
Normal file
7
global.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "7.0.0",
|
||||
"rollForward": "latestMajor",
|
||||
"allowPrerelease": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user