diff --git a/database/README.md b/database/README.md index 7950470..adfdf56 100644 --- a/database/README.md +++ b/database/README.md @@ -18,7 +18,7 @@ MS SQL database ``` ``` - docker run -d --name POLLOR_DATABASE -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=myPassw0rd' -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest + docker run -d --name POLLOR_DATABASE -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=myPassw0rd" -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest ``` ``` diff --git a/database/migration.sql b/database/migration.sql index 240b194..295171e 100644 --- a/database/migration.sql +++ b/database/migration.sql @@ -14,11 +14,14 @@ GO CREATE TABLE [dbo].[users]( [id] [int] IDENTITY(1,1) NOT NULL, [emailaddress] [nvarchar](256) NOT NULL, - [first_name] [nvarchar](64) NOT NULL, - [last_name] [nvarchar](64) NOT NULL, - [profile_username] [nvarchar](64) NOT NULL, + [username] [nvarchar](64) NOT NULL, + [password] [nvarchar](128) NOT NULL, + [first_name] [nvarchar](64) NULL, + [last_name] [nvarchar](64) NULL, + [role] [nvarchar](32) NULL DEFAULT 'user', [created_at] [datetime] NOT NULL, - CONSTRAINT PK_users PRIMARY KEY NONCLUSTERED (id) + CONSTRAINT PK_users PRIMARY KEY NONCLUSTERED (id), + CONSTRAINT UC_Users UNIQUE (id,emailaddress,username) ) ON [PRIMARY] GO diff --git a/database/seed.sql b/database/seed.sql index f6ed6d2..34c4451 100644 --- a/database/seed.sql +++ b/database/seed.sql @@ -1,8 +1,8 @@ USE [pollor_db] GO -INSERT INTO users (emailaddress, first_name, last_name, profile_username, created_at) -VALUES ('test@test.nl', 'Tester', 'Test', 'Testing', '1970-01-01T00:00:01'); +INSERT INTO users (emailaddress, first_name, last_name, username, password, created_at) +VALUES ('test@test.nl', 'Tester', 'Test', 'Testing', '', '1970-01-01T00:00:01'); GO INSERT INTO polls (user_id, question, ending_date, created_at) diff --git a/pollor.Server/.env.example b/pollor.Server/.env.example index 1c5ea20..eaad519 100644 --- a/pollor.Server/.env.example +++ b/pollor.Server/.env.example @@ -3,4 +3,6 @@ DB_SERVER=localhost\MSSQLSERVERx DB_NAME=name DB_UID= DB_PASSWORD= -ASPNETCORE_ENVIRONMENT=Development \ No newline at end of file +ASPNETCORE_ENVIRONMENT=Development +SECRET_JWT_KEY=secret-hash-key +JWT_TOKEN_DOMAIN=https://localhost:5001 diff --git a/pollor.Server/Controllers/AnswersController.cs b/pollor.Server/Controllers/AnswersController.cs index 162dc08..30b175d 100644 --- a/pollor.Server/Controllers/AnswersController.cs +++ b/pollor.Server/Controllers/AnswersController.cs @@ -3,11 +3,13 @@ using pollor.Server.Models; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace answeror.Server.Controllers { [ApiController] - [Route("[controller]")] + [Route("api/")] public class AnswersController : ControllerBase { private readonly ILogger _logger; @@ -17,7 +19,7 @@ public AnswersController(ILogger logger) _logger = logger; } - [HttpGet(Name = "GetAnswersController")] + [HttpGet("answers")] public IActionResult GetAllAnswers() { try { @@ -26,7 +28,7 @@ public IActionResult GetAllAnswers() .Include(a => a.Votes) .ToList(); if (answers.IsNullOrEmpty()) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(answers); } @@ -37,17 +39,17 @@ public IActionResult GetAllAnswers() } } - [HttpGet("{id}")] + [HttpGet("answer/{id}")] public IActionResult GetAnswerById(int id) { try { using (var context = new PollorDbContext()) { AnswerModel? answer = context.Answers - .Where(p => p.Id.Equals(id)) + .Where(p => p.id.Equals(id)) .Include(a => a.Votes) .FirstOrDefault(); if (answer == null) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(answer); } @@ -57,5 +59,26 @@ public IActionResult GetAnswerById(int id) return StatusCode(500, new { message = ex.Message}); } } + + [HttpPost("answer")] + [Authorize] + public IActionResult AddAnswer(AnswerModel answer) + { + try { + using (var context = new PollorDbContext()) { + EntityEntry newAnswer = context.Answers.Add(answer); + context.SaveChanges(); + + if (newAnswer == null) { + return NotFound(new { message = "No records found" }); + } + return Created("answer/" + newAnswer.Entity.id.ToString(), newAnswer.Entity); + } + } + catch (Exception ex) { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { message = ex.Message}); + } + } } } diff --git a/pollor.Server/Controllers/AuthController.cs b/pollor.Server/Controllers/AuthController.cs new file mode 100644 index 0000000..d02ed59 --- /dev/null +++ b/pollor.Server/Controllers/AuthController.cs @@ -0,0 +1,196 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Identity; +using pollor.Server.Models; +using pollor.Server.Services; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using System.Security.Principal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.AspNetCore.Authorization; +using pollor.Server.Controllers; +using System.Data; + +[Route("api/auth")] +[ApiController] +public class AuthController : ControllerBase +{ + private readonly ILogger _logger; + + public AuthController(ILogger logger) + { + _logger = logger; + } + + + [HttpPost("register")] + public IActionResult Register([FromBody] RegisterModel registerUser) + { + if (registerUser is null) + { + return BadRequest(new { message = "Invalid client request" }); + } + + if (registerUser.password!.Length < 8) { + return BadRequest(new { message = "Password must be longer than 8 characters." }); + } + + bool isUsernameAvailable = new PollorDbContext().UserAuthModel.Where(u => u.username!.ToLower().Equals(registerUser.username!.ToLower())).IsNullOrEmpty(); + if (isUsernameAvailable == false) { + return BadRequest(new { message = "Username is already taken, please login or use another username." }); + } + + bool isEmailAvailable = new PollorDbContext().UserAuthModel.Where(u => u.emailaddress!.ToLower().Equals(registerUser.emailaddress!.ToLower())).IsNullOrEmpty(); + if (isEmailAvailable == false) { + return BadRequest(new { message = "Emailaddress is already taken, please login or use another emailaddress." }); + } + + var hasher = new PasswordHasher(); + var hashedPass = hasher.HashPassword(registerUser, registerUser.password!); + UserAuthModel tempUser = new UserAuthModel() { + username = registerUser.username, + password = hashedPass, + emailaddress = registerUser.emailaddress, + created_at = DateTime.Now, + }; + + try + { + using (var context = new PollorDbContext()) + { + // Create new user + EntityEntry createdUser = context.UserAuthModel.Add(tempUser); + context.SaveChanges(); + + // Get full user data + UserModel? newUser = context.Users + .Where(u => u.id.Equals(createdUser.Entity.id) && + u.username!.Equals(createdUser.Entity.username) && + u.emailaddress!.Equals(createdUser.Entity.emailaddress)) + .FirstOrDefault(); + if (newUser == null) + { + return NotFound(new { message = "User not found..." }); + } + var tokeOptions = GetJwtTokenOptions(1, newUser); + var tokenString = new JwtSecurityTokenHandler().WriteToken(tokeOptions); + return Created("user/" + newUser.id, new AuthenticatedResponse { token = tokenString, user = newUser }); + + } + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { message = ex.Message }); + } + } + + + [HttpPost("login")] + public IActionResult Login([FromBody] LoginModel loginUser) + { + if (loginUser is null) + { + return BadRequest(new { message = "Invalid client request" }); + } + + var authUser = new PollorDbContext().UserAuthModel.Where(u => u.username!.ToLower().Equals(loginUser.username!.ToLower())).FirstOrDefault(); + if (authUser == null) { + return Unauthorized(new { message = "Username or password is wrong!" }); + } + + var hasher = new PasswordHasher(); + PasswordVerificationResult passwordIsOk = hasher.VerifyHashedPassword(loginUser, authUser.password!, loginUser.password!); + + if (passwordIsOk == PasswordVerificationResult.Failed) { + return Unauthorized(new { message = "Username or password is wrong!" }); + } + + if (authUser.username == loginUser.username && (passwordIsOk == PasswordVerificationResult.Success || passwordIsOk == PasswordVerificationResult.SuccessRehashNeeded)) + { + if (passwordIsOk == PasswordVerificationResult.SuccessRehashNeeded) { + // rehash password and save to DB + _logger.LogError("Rehash password and save to DB"); + } + + int tokenLongerValid = (bool)loginUser.tokenLongerValid ? 31 : 1;// true = 31, false = 1 + var currentUser = new PollorDbContext().Users.Where(u => u.username!.ToLower().Equals(authUser.username!.ToLower())).FirstOrDefault(); + var tokenOptions = GetJwtTokenOptions(tokenLongerValid, currentUser!); + var tokenString = new JwtSecurityTokenHandler().WriteToken(tokenOptions); + + return Ok(new AuthenticatedResponse { token = tokenString, user = currentUser}); + } + + return Unauthorized(new { message = "something went wrong" } ); + } + + [HttpPost("validate")] + [Authorize] + public IActionResult Validate([FromBody] ValidateTokenModel validateTokenModel) + { + // Retrieve the JWT token from the Authorization header + var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last(); + + if (string.IsNullOrEmpty(token)) + { + return BadRequest(new { message = "Token is missing" }); + } + + SecurityToken validatedToken; + IPrincipal principal = new JwtSecurityTokenHandler().ValidateToken(token, AuthService.GetValidationParameters(), out validatedToken); + + // The user is authenticated, and you can access user information + var userClaims = HttpContext.User; + // Perform additional validation or return success response + + var username = userClaims.Claims.Where(e => e.Type.EndsWith("identity/claims/name")).Select(e => e.Value).SingleOrDefault(); + var userId = userClaims.Claims.Where(e => e.Type.EndsWith("identity/claims/nameidentifier")).Select(e => e.Value).SingleOrDefault(); + var userRole = userClaims.Claims.Where(e => e.Type.EndsWith("identity/claims/role")).Select(e => e.Value).SingleOrDefault(); + + try + { + using (var context = new PollorDbContext()) + { + UserModel? user = context.Users + .Where(u => u.id.ToString().Equals(userId) && + u.username!.Equals(username) && + u.role!.Equals(userRole)) + .FirstOrDefault(); + if (user == null) + { + return NotFound(new { message = "User not found..." }); + } + return Ok(new AuthenticatedResponse { token = validateTokenModel.token, user = user }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { message = ex.Message }); + } + } + + private JwtSecurityToken GetJwtTokenOptions (int tokenValidForXDays, UserModel user) { + var jwtClaims = new List + { + new Claim(ClaimTypes.Name, user.username!), + new Claim(ClaimTypes.NameIdentifier, user.id.ToString()), + new Claim(ClaimTypes.Role, user.role!) + }; + + String jwtTokenDomain = Environment.GetEnvironmentVariable("JWT_TOKEN_DOMAIN")!.Split(',').FirstOrDefault()!; + + var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET_JWT_KEY")!)); + var signinCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256); + var tokeOptions = new JwtSecurityToken( + issuer: jwtTokenDomain, + audience: jwtTokenDomain, + claims: jwtClaims, + expires: DateTime.Now.AddDays(tokenValidForXDays), + signingCredentials: signinCredentials + ); + return tokeOptions; + } +} \ No newline at end of file diff --git a/pollor.Server/Controllers/PollsController.cs b/pollor.Server/Controllers/PollsController.cs index bef93d0..7e61183 100644 --- a/pollor.Server/Controllers/PollsController.cs +++ b/pollor.Server/Controllers/PollsController.cs @@ -1,5 +1,7 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using pollor.Server.Models; @@ -8,7 +10,7 @@ namespace pollor.Server.Controllers { [ApiController] - [Route("[controller]")] + [Route("api/")] public class PollsController : ControllerBase { private readonly ILogger _logger; @@ -18,7 +20,7 @@ public PollsController(ILogger logger) _logger = logger; } - [HttpGet(Name = "GetPollsController")] + [HttpGet("polls")] public IActionResult GetAllPolls() { try { @@ -28,7 +30,7 @@ public IActionResult GetAllPolls() .ThenInclude(a => a.Votes) .ToList(); if (polls.IsNullOrEmpty()) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(polls); } @@ -39,18 +41,18 @@ public IActionResult GetAllPolls() } } - [HttpGet("{id}")] + [HttpGet("poll/{id}")] public IActionResult GetPollById(int id) { try { using (var context = new PollorDbContext()) { PollModel? poll = context.Polls - .Where(p => p.Id.Equals(id)) + .Where(p => p.id.Equals(id)) .Include(p => p.Answers) .ThenInclude(a => a.Votes) .FirstOrDefault(); if (poll == null) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(poll); } @@ -60,5 +62,26 @@ public IActionResult GetPollById(int id) return StatusCode(500, new { message = ex.Message}); } } + + [HttpPost("poll")] + [Authorize] + public IActionResult AddPoll(PollModel poll) + { + try { + using (var context = new PollorDbContext()) { + EntityEntry newPoll = context.Polls.Add(poll); + context.SaveChanges(); + + if (newPoll == null) { + return NotFound(new { message = "No records found" }); + } + return Created("poll/" + newPoll.Entity.id.ToString(), newPoll.Entity); + } + } + catch (Exception ex) { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { message = ex.Message}); + } + } } } diff --git a/pollor.Server/Controllers/UsersController.cs b/pollor.Server/Controllers/UsersController.cs index 8fcd45b..6a3e8d2 100644 --- a/pollor.Server/Controllers/UsersController.cs +++ b/pollor.Server/Controllers/UsersController.cs @@ -1,13 +1,16 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using pollor.Server.Models; using pollor.Server.Services; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Principal; namespace pollor.Server.Controllers { [ApiController] - [Route("[controller]")] + [Route("api/")] public class UsersController : ControllerBase { private readonly ILogger _logger; @@ -17,7 +20,7 @@ public UsersController(ILogger logger) _logger = logger; } - [HttpGet(Name = "GetUsersController")] + [HttpGet("users")] public IActionResult GetAllUsers() { try { @@ -28,7 +31,7 @@ public IActionResult GetAllUsers() .ThenInclude(a => a.Votes) .ToList(); if (users.IsNullOrEmpty()) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(users); } @@ -39,19 +42,65 @@ public IActionResult GetAllUsers() } } - [HttpGet("{id}")] + [HttpGet("user")] + [Authorize] + public IActionResult GetCurrentUser() + { + // Retrieve the JWT token from the Authorization header + var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last(); + + if (string.IsNullOrEmpty(token)) + { + return BadRequest(new { message = "Token is missing" }); + } + + SecurityToken validatedToken; + IPrincipal principal = new JwtSecurityTokenHandler().ValidateToken(token, AuthService.GetValidationParameters(), out validatedToken); + + // The user is authenticated, and you can access user information + var userClaims = HttpContext.User; + // Perform additional validation or return success response + + var username = userClaims.Claims.Where(e => e.Type.EndsWith("identity/claims/name")).Select(e => e.Value).SingleOrDefault(); + var userId = userClaims.Claims.Where(e => e.Type.EndsWith("identity/claims/nameidentifier")).Select(e => e.Value).SingleOrDefault(); + var userRole = userClaims.Claims.Where(e => e.Type.EndsWith("identity/claims/role")).Select(e => e.Value).SingleOrDefault(); + + try + { + using (var context = new PollorDbContext()) + { + UserModel? user = context.Users + .Where(u => u.id.ToString().Equals(userId) && + u.username!.Equals(username) && + u.role!.Equals(userRole)) + .FirstOrDefault(); + if (user == null) + { + return NotFound(new { message = "No records found" }); + } + return Ok(user); + } + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { message = ex.Message }); + } + } + + [HttpGet("user/{id}")] public IActionResult GetUserById(int id) { try { using (var context = new PollorDbContext()) { UserModel? user = context.Users - .Where(u => u.Id.Equals(id)) + .Where(u => u.id.Equals(id)) .Include(u => u.Polls) .ThenInclude(p => p.Answers) .ThenInclude(a => a.Votes) .FirstOrDefault(); if (user == null) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(user); } diff --git a/pollor.Server/Controllers/VotesController.cs b/pollor.Server/Controllers/VotesController.cs index 615fbba..46658e7 100644 --- a/pollor.Server/Controllers/VotesController.cs +++ b/pollor.Server/Controllers/VotesController.cs @@ -1,5 +1,7 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.IdentityModel.Tokens; using pollor.Server.Models; using pollor.Server.Services; @@ -7,7 +9,7 @@ namespace pollor.Server.Controllers { [ApiController] - [Route("[controller]")] + [Route("api/")] public class VotesController : ControllerBase { private readonly ILogger _logger; @@ -17,14 +19,14 @@ public VotesController(ILogger logger) _logger = logger; } - [HttpGet(Name = "GetVotesController")] + [HttpGet("votes")] public IActionResult GetAllVotes() { try { using (var context = new PollorDbContext()) { List? votes = context.Votes.ToList(); if (votes.IsNullOrEmpty()) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(votes); } @@ -35,16 +37,16 @@ public IActionResult GetAllVotes() } } - [HttpGet("{id}")] + [HttpGet("vote/{id}")] public IActionResult GetVoteById(int id) { try { using (var context = new PollorDbContext()) { VoteModel? vote = context.Votes - .Where(v => v.Id.Equals(id)) + .Where(v => v.id.Equals(id)) .FirstOrDefault(); if (vote == null) { - return NotFound(); + return NotFound(new { message = "No records found" }); } return Ok(vote); } @@ -54,5 +56,26 @@ public IActionResult GetVoteById(int id) return StatusCode(500, new { message = ex.Message}); } } + + [HttpPost("vote")] + [Authorize] + public IActionResult AddVote(VoteModel vote) + { + try { + using (var context = new PollorDbContext()) { + EntityEntry newVote = context.Votes.Add(vote); + context.SaveChanges(); + + if (newVote == null) { + return NotFound(new { message = "No records found" }); + } + return Created("vote/" + newVote.Entity.id.ToString(), newVote.Entity); + } + } + catch (Exception ex) { + _logger.LogError(ex, ex.Message); + return StatusCode(500, new { message = ex.Message}); + } + } } } diff --git a/pollor.Server/Models/AnswerModel.cs b/pollor.Server/Models/AnswerModel.cs index 99f99ba..2f062b7 100644 --- a/pollor.Server/Models/AnswerModel.cs +++ b/pollor.Server/Models/AnswerModel.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace pollor.Server.Models @@ -8,8 +9,9 @@ public partial class AnswerModel : SuperModel public AnswerModel() { Votes = new List(); } - - public virtual int Poll_id { get; set; } + + public int poll_id { get; set; } + [StringLength(256)] public string? poll_answer { get; set; } [ForeignKey("answer_id")] // ForeignKey attribute in the VoteModel diff --git a/pollor.Server/Models/AuthModel.cs b/pollor.Server/Models/AuthModel.cs new file mode 100644 index 0000000..199a01a --- /dev/null +++ b/pollor.Server/Models/AuthModel.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace pollor.Server.Models +{ + public class RegisterModel + { + [Required, StringLength(256)] + public string? emailaddress { get; set; } + [Required, StringLength(64)] + public string? username { get; set; } + [Required, StringLength(128)] + public string? password { get; set; } + [Required, StringLength(128)] + public string? confirmPassword { get; set; } + } + + public class LoginModel + { + [Required, StringLength(64)] + public string? username { get; set; } + [Required, StringLength(128)] + public string? password { get; set; } + public bool tokenLongerValid { get; set; } = false; + } + + public class ChangePasswordModel + { + [Required] + public int? id { get; set; } + [Required, StringLength(128)] + public string? newpassword { get; set; } + [Required, StringLength(128)] + public string? confirmPassword { get; set; } + } + + public class ValidateTokenModel + { + public string? token { get; set; } + [Required, StringLength(32)] + public string? role { get; set; } + } +} \ No newline at end of file diff --git a/pollor.Server/Models/AuthenticatedResponse.cs b/pollor.Server/Models/AuthenticatedResponse.cs new file mode 100644 index 0000000..6855ec9 --- /dev/null +++ b/pollor.Server/Models/AuthenticatedResponse.cs @@ -0,0 +1,8 @@ +namespace pollor.Server.Models +{ + public class AuthenticatedResponse + { + public string? token { get; set; } + public UserModel? user { get; set; } + } +} \ No newline at end of file diff --git a/pollor.Server/Models/PollModel.cs b/pollor.Server/Models/PollModel.cs index 435022b..953d2b3 100644 --- a/pollor.Server/Models/PollModel.cs +++ b/pollor.Server/Models/PollModel.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace pollor.Server.Models @@ -9,11 +10,12 @@ public PollModel() { Answers = new List(); } - public virtual int User_id { get; set; } - public string? Question { get; set; } - public DateTime Ending_date { get; set; } + public int user_id { get; set; } + [StringLength(512)] + public string? question { get; set; } + public DateTime ending_date { get; set; } - [ForeignKey("Poll_id")] // ForeignKey attribute in the AnswerModel + [ForeignKey("poll_id")] // ForeignKey attribute in the AnswerModel public virtual ICollection Answers { get; set; } } } \ No newline at end of file diff --git a/pollor.Server/Models/SuperModel.cs b/pollor.Server/Models/SuperModel.cs index 48805c3..f4e2754 100644 --- a/pollor.Server/Models/SuperModel.cs +++ b/pollor.Server/Models/SuperModel.cs @@ -1,13 +1,13 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace pollor.Server.Models { public class SuperModel { - - [Column("id")] - public int Id { get; set; } - public DateTime Created_at { get; set; } + [Key, Column("id")] + public int id { get; set; } + public DateTime created_at { get; set; } } } diff --git a/pollor.Server/Models/UserModel.cs b/pollor.Server/Models/UserModel.cs index a88b8ce..cd73982 100644 --- a/pollor.Server/Models/UserModel.cs +++ b/pollor.Server/Models/UserModel.cs @@ -1,19 +1,39 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace pollor.Server.Models { + public class BaseUserModel : SuperModel + { + [StringLength(64)] + public string? username { get; set; } + [StringLength(256)] + public string? emailaddress { get; set; } + } + [Table("users")] - public class UserModel : SuperModel + public class UserModel: BaseUserModel { public UserModel() { Polls = new List(); } - + [StringLength(64)] public string? first_name { get; set; } + [StringLength(64)] public string? last_name { get; set; } - public string? profile_username { get; set; } + [StringLength(32)] + public string? role { get; set; } [ForeignKey("user_id")] // ForeignKey attribute in the PollModel public virtual ICollection Polls { get; set; } } + + [Table("users", Schema = "dbo")] + public class UserAuthModel : BaseUserModel + { + [DataType(DataType.Password), StringLength(128)] + public string? password { get; set; } + [NotMapped, StringLength(128)] + public string? confirmPassword { get; set; } + } } \ No newline at end of file diff --git a/pollor.Server/Models/VoteModel.cs b/pollor.Server/Models/VoteModel.cs index cf0907e..3f98f90 100644 --- a/pollor.Server/Models/VoteModel.cs +++ b/pollor.Server/Models/VoteModel.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace pollor.Server.Models @@ -5,9 +6,12 @@ namespace pollor.Server.Models [Table("votes")] public partial class VoteModel : SuperModel { - public int Answer_id { get; set; } + public int answer_id { get; set; } + [MaxLength(4)] public byte[]? ipv4_address { get; set; } + [MaxLength(16)] public byte[]? ipv6_address { get; set; } + [MaxLength(12)] public char[]? mac_address { get; set; } public DateTime voted_at { get; set; } } diff --git a/pollor.Server/Program.cs b/pollor.Server/Program.cs index 07fbb8e..2fbd217 100644 --- a/pollor.Server/Program.cs +++ b/pollor.Server/Program.cs @@ -1,4 +1,8 @@ +using System.Text; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; using pollor.Server.Services; var builder = WebApplication.CreateBuilder(args); @@ -23,10 +27,40 @@ options.AddDefaultPolicy( policy => { - policy.WithOrigins(corsDomains); // loading array of .env values as allowed CORS domains + policy.WithOrigins(corsDomains) // loading array of .env values as allowed CORS domains + .AllowAnyHeader() + .AllowAnyMethod(); }); }); +/* get secret private jwt key value */ +String secretJwtKey = Environment.GetEnvironmentVariable("SECRET_JWT_KEY")!; + +/* If values are null or empty, give error message that values are missing in .env */ +if (secretJwtKey == null) +{ + throw new InvalidOperationException("secretJwtKey contains no value. Make sure SECRET_JWT_KEY is set in .env"); +} + +/* Add JWT authentication */ +builder.Services.AddAuthentication(opt => { + opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = "https://localhost:5001", + ValidAudience = "https://localhost:5001", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretJwtKey)) + }; + }); + // Add services to the container. builder.Services.AddControllers() @@ -36,13 +70,38 @@ });; // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(option => +{ + option.SwaggerDoc("v1", new OpenApiInfo { Title = "Pollor API", Version = "v1" }); + option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + option.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type=ReferenceType.SecurityScheme, + Id="Bearer" + }, + Name = "Bearer", + In = ParameterLocation.Header, + }, + new List() + } + }); +}); var app = builder.Build(); -app.UseDefaultFiles(); -app.UseStaticFiles(); - app.UseCors(); // Configure the HTTP request pipeline. @@ -53,9 +112,11 @@ } app.UseHttpsRedirection(); - +app.UseAuthentication(); app.UseAuthorization(); +app.UseStatusCodePages(); + app.MapControllers().RequireCors(); app.MapFallbackToFile("/index.html"); diff --git a/pollor.Server/Properties/launchSettings.json b/pollor.Server/Properties/launchSettings.json index 7ca1d1e..39eca2b 100644 --- a/pollor.Server/Properties/launchSettings.json +++ b/pollor.Server/Properties/launchSettings.json @@ -25,7 +25,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:7267;http://localhost:5010", + "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" diff --git a/pollor.Server/Services/AuthService.cs b/pollor.Server/Services/AuthService.cs new file mode 100644 index 0000000..97b7261 --- /dev/null +++ b/pollor.Server/Services/AuthService.cs @@ -0,0 +1,24 @@ +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace pollor.Server.Services +{ + public class AuthService + { + public static TokenValidationParameters GetValidationParameters() + { + var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET_JWT_KEY")!)); + String jwtTokenDomain = Environment.GetEnvironmentVariable("JWT_TOKEN_DOMAIN")!.Split(',').FirstOrDefault()!; + + return new TokenValidationParameters() + { + ValidateLifetime = true, + ValidateAudience = true, + ValidateIssuer = true, + ValidIssuer = jwtTokenDomain, + ValidAudience = jwtTokenDomain, + IssuerSigningKey = secretKey // The same key as the one that generate the token + }; + } + } +} diff --git a/pollor.Server/Services/PollorDbContext.cs b/pollor.Server/Services/PollorDbContext.cs index 855482e..f37372b 100644 --- a/pollor.Server/Services/PollorDbContext.cs +++ b/pollor.Server/Services/PollorDbContext.cs @@ -9,6 +9,7 @@ public class PollorDbContext : DbContext //entities public DbSet Users { get; set; } + public DbSet UserAuthModel { get; set; } public DbSet Polls { get; set; } public DbSet Votes { get; set; } public DbSet Answers { get; set; } diff --git a/pollor.Server/pollor.Server.csproj b/pollor.Server/pollor.Server.csproj index e9f73ad..f169e06 100644 --- a/pollor.Server/pollor.Server.csproj +++ b/pollor.Server/pollor.Server.csproj @@ -12,6 +12,7 @@ + 8.*-* diff --git a/pollor.client/.env.example b/pollor.client/.env.example index a975e12..aa02a07 100644 --- a/pollor.client/.env.example +++ b/pollor.client/.env.example @@ -1 +1 @@ -API_URL='https://localhost:7267' +API_URL='https://localhost:5001' diff --git a/pollor.client/angular.json b/pollor.client/angular.json index 153c524..b307ff4 100644 --- a/pollor.client/angular.json +++ b/pollor.client/angular.json @@ -35,10 +35,12 @@ ], "styles": [ "src/styles.css", - "./node_modules/bootstrap/dist/css/bootstrap.min.css" + "./node_modules/bootstrap/dist/css/bootstrap.min.css", + "./node_modules/bootstrap-icons/font/bootstrap-icons.css" + ], "scripts": [ - "./node_modules/bootstrap/dist/js/bootstrap.min.js", + "./node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" ] }, "configurations": { @@ -55,7 +57,7 @@ "maximumError": "4kb" } ], - "outputHashing": "all", + "outputHashing": "all" }, "development": { "optimization": false, @@ -96,10 +98,11 @@ ], "styles": [ "src/styles.css", - "node_modules/bootstrap/dist/css/bootstrap.min.css" + "node_modules/bootstrap/dist/css/bootstrap.min.css", + "./node_modules/bootstrap-icons/font/bootstrap-icons.css" ], "scripts": [ - "./node_modules/bootstrap/dist/js/bootstrap.min.js", + "./node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" ], "karmaConfig": "karma.conf.js" } diff --git a/pollor.client/package-lock.json b/pollor.client/package-lock.json index 9ea2a10..9e4681d 100644 --- a/pollor.client/package-lock.json +++ b/pollor.client/package-lock.json @@ -1929,51 +1929,6 @@ "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", "dev": true }, - "@fortawesome/angular-fontawesome": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.14.1.tgz", - "integrity": "sha512-Yb5HLiEOAxjSLEcaOM51CKIrzdfvoDafXVJERm9vufxfZkVZPZJgrZRgqwLVpejgq4/Ez6TqHZ6SqmJwdtRF6g==", - "requires": { - "tslib": "^2.6.2" - } - }, - "@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", - "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==" - }, - "@fortawesome/fontawesome-svg-core": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", - "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", - "requires": { - "@fortawesome/fontawesome-common-types": "6.5.1" - } - }, - "@fortawesome/free-brands-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.1.tgz", - "integrity": "sha512-093l7DAkx0aEtBq66Sf19MgoZewv1zeY9/4C7vSKPO4qMwEsW/2VYTUTpBtLwfb9T2R73tXaRDPmE4UqLCYHfg==", - "requires": { - "@fortawesome/fontawesome-common-types": "6.5.1" - } - }, - "@fortawesome/free-regular-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.1.tgz", - "integrity": "sha512-m6ShXn+wvqEU69wSP84coxLbNl7sGVZb+Ca+XZq6k30SzuP3X4TfPqtycgUh9ASwlNh5OfQCd8pDIWxl+O+LlQ==", - "requires": { - "@fortawesome/fontawesome-common-types": "6.5.1" - } - }, - "@fortawesome/free-solid-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz", - "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==", - "requires": { - "@fortawesome/fontawesome-common-types": "6.5.1" - } - }, "@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -3452,6 +3407,11 @@ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.2.tgz", "integrity": "sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==" }, + "bootstrap-icons": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", + "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/pollor.client/package.json b/pollor.client/package.json index 79cc7a3..6e9e205 100644 --- a/pollor.client/package.json +++ b/pollor.client/package.json @@ -23,14 +23,10 @@ "@angular/platform-browser": "^17.0.7", "@angular/platform-browser-dynamic": "^17.0.7", "@angular/router": "^17.0.7", - "@fortawesome/angular-fontawesome": "^0.14.1", - "@fortawesome/fontawesome-svg-core": "^6.4.2", - "@fortawesome/free-brands-svg-icons": "^6.4.2", - "@fortawesome/free-regular-svg-icons": "^6.4.2", - "@fortawesome/free-solid-svg-icons": "^6.4.2", "@ng-bootstrap/ng-bootstrap": "^16.0.0", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.2", + "bootstrap-icons": "^1.11.3", "jest-editor-support": "*", "run-script-os": "*", "rxjs": "~7.8.0", diff --git a/pollor.client/src/app/alert-message/alert-message.css b/pollor.client/src/app/alert-message/alert-message.css new file mode 100644 index 0000000..ee8a674 --- /dev/null +++ b/pollor.client/src/app/alert-message/alert-message.css @@ -0,0 +1,7 @@ +.alert-messages { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 30%; + z-index: 120; +} \ No newline at end of file diff --git a/pollor.client/src/app/alert-message/alert-message.html b/pollor.client/src/app/alert-message/alert-message.html new file mode 100644 index 0000000..0cc3abf --- /dev/null +++ b/pollor.client/src/app/alert-message/alert-message.html @@ -0,0 +1,9 @@ +
+ @for (alert of getAlertMessages(); track alert) { + +

+
+

+
+ } +
\ No newline at end of file diff --git a/pollor.client/src/app/alert-message/alert-message.ts b/pollor.client/src/app/alert-message/alert-message.ts new file mode 100644 index 0000000..c141241 --- /dev/null +++ b/pollor.client/src/app/alert-message/alert-message.ts @@ -0,0 +1,67 @@ +import { Component } from '@angular/core'; +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; + +interface Alert { + type: string; + title: string; + message: string; +} + +const ALERTS: Alert[] = [ + // { + // type: 'info', + // title: 'Test alert', + // message: 'This is an test alert', + // } +]; + +@Component({ + selector: 'ngbd-alert-message', + standalone: true, + imports: [NgbAlertModule], + templateUrl: './alert-message.html', + styleUrl: './alert-message.css' + +}) +export class AlertMessage { + static alerts: Alert[] = ALERTS; + + constructor() { + this.reset(); + } + + close(alert: Alert) { + AlertMessage.alerts.splice(AlertMessage.alerts.indexOf(alert), 1); + } + + reset() { + AlertMessage.alerts = []; + } + + static addAlert(alertType: string, alertTitle: string, alertMessage: string, timeout: number = 10000) { + const newAlert : Alert = { + type: alertType, + title: alertTitle, + message: alertMessage + }; + + AlertMessage.alerts.unshift(newAlert); + + setTimeout(() => { + AlertMessage.alerts.splice(AlertMessage.alerts.indexOf(newAlert), 1); + }, timeout); // automatic close the alert after x miliseconds based + } + + + static addSuccessAlert(alertMessage: string) { + AlertMessage.addAlert("success", "Success!", alertMessage); + } + + static addErrorAlert(alertMessage: string) { + AlertMessage.addAlert("danger", "An error occured", alertMessage); + } + + getAlertMessages() { + return AlertMessage.alerts; + } +} diff --git a/pollor.client/src/app/app-routing.module.ts b/pollor.client/src/app/app-routing.module.ts index 49b6658..67b49c5 100644 --- a/pollor.client/src/app/app-routing.module.ts +++ b/pollor.client/src/app/app-routing.module.ts @@ -2,10 +2,18 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { HomeComponent } from './home/home.component'; import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; -import { PollsComponent } from './polls/component/polls.component'; +import { PollsComponent } from './polls/polls.component'; +import { UserLoginComponent } from './user-login/user-login.component'; +import { UserRegisterComponent } from './user-register/user-register.component'; +import { UserProfileComponent } from './user-profile/user-profile.component'; +import { UserAdminProfileComponent } from './user-admin-profile/user-admin-profile.component'; const routes: Routes = [ { path: '', component: HomeComponent }, + { path: 'account/login', component: UserLoginComponent }, + { path: 'account/register', component: UserRegisterComponent }, + { path: 'account/profile', component: UserProfileComponent }, + { path: 'account/admin-profile', component: UserAdminProfileComponent }, { path: 'polls', component: PollsComponent }, { path: '**', component: PageNotFoundComponent }, // route for 404 page ]; diff --git a/pollor.client/src/app/app.component.css b/pollor.client/src/app/app.component.css index 7a4fe7e..0078b1c 100644 --- a/pollor.client/src/app/app.component.css +++ b/pollor.client/src/app/app.component.css @@ -1,6 +1,5 @@ :host { max-width: 1280px; - padding: 2rem; text-align: center; } @@ -16,3 +15,13 @@ th, td { padding-left: 1rem; padding-right: 1rem; } + +#page-container { + position: relative; + min-height: 100vh; + } + +#content-wrap { + padding: 2.5rem; + padding-bottom: 12.5rem; /* Footer height, must be same or higher as in footer.component.css */ +} \ No newline at end of file diff --git a/pollor.client/src/app/app.component.html b/pollor.client/src/app/app.component.html index 20c39af..54f0477 100644 --- a/pollor.client/src/app/app.component.html +++ b/pollor.client/src/app/app.component.html @@ -1,5 +1,8 @@ - - - - - +
+ +
+ +
+ + +
\ No newline at end of file diff --git a/pollor.client/src/app/app.module.ts b/pollor.client/src/app/app.module.ts index baa9450..42312b5 100644 --- a/pollor.client/src/app/app.module.ts +++ b/pollor.client/src/app/app.module.ts @@ -1,17 +1,25 @@ -import { HttpClientModule } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +import { AuthInterceptor } from '../services/auth.interceptor'; import { HeaderComponent } from './header/header.component'; import { FooterComponent } from './footer/footer.component'; import { HomeComponent } from './home/home.component'; import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; -import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { PollsComponent } from './polls/component/polls.component'; +import { PollsComponent } from './polls/polls.component'; +import { UserProfileComponent } from './user-profile/user-profile.component'; +import { UserLoginComponent } from './user-login/user-login.component'; +import { UserRegisterComponent } from './user-register/user-register.component'; +import { AlertMessage } from './alert-message/alert-message'; +import { UserAdminProfileComponent } from './user-admin-profile/user-admin-profile.component'; @NgModule({ declarations: [ @@ -19,19 +27,33 @@ import { PollsComponent } from './polls/component/polls.component'; HeaderComponent, HomeComponent, PageNotFoundComponent, - PollsComponent + PollsComponent, + UserProfileComponent, + UserLoginComponent, + UserRegisterComponent, + UserAdminProfileComponent ], imports: [ - BrowserModule, HttpClientModule, + BrowserModule, + RouterModule, + HttpClientModule, + FormsModule, + ReactiveFormsModule, AppRoutingModule, NgbModule, RouterOutlet, RouterLink, RouterLinkActive, - FontAwesomeModule, - FooterComponent + FooterComponent, + AlertMessage + ], + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true, + } ], - providers: [], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/pollor.client/src/app/footer/footer.component.css b/pollor.client/src/app/footer/footer.component.css index 82fd693..041b5d0 100644 --- a/pollor.client/src/app/footer/footer.component.css +++ b/pollor.client/src/app/footer/footer.component.css @@ -2,6 +2,6 @@ position: absolute; bottom: 0; width: 100%; - /* Set the fixed height of the footer here */ - background-color: #f5f5f5; -} + height: 10rem; /* Footer height, must be same or lower as in app.component.css */ + z-index: 50; +} \ No newline at end of file diff --git a/pollor.client/src/app/footer/footer.component.html b/pollor.client/src/app/footer/footer.component.html index 383f6df..a70c55a 100644 --- a/pollor.client/src/app/footer/footer.component.html +++ b/pollor.client/src/app/footer/footer.component.html @@ -10,8 +10,8 @@ href="#!" role="button" data-mdb-ripple-color="dark"> - - + + - + diff --git a/pollor.client/src/app/footer/footer.component.ts b/pollor.client/src/app/footer/footer.component.ts index 1e04a22..f043e96 100644 --- a/pollor.client/src/app/footer/footer.component.ts +++ b/pollor.client/src/app/footer/footer.component.ts @@ -1,18 +1,11 @@ import { Component } from '@angular/core'; import { environment } from './../../environments/environment'; -import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { faLinkedin, faGithub } from '@fortawesome/free-brands-svg-icons'; - @Component({ selector: 'app-footer', standalone: true, - imports: [FontAwesomeModule], templateUrl: './footer.component.html', styleUrl: './footer.component.css' }) export class FooterComponent { public appVersion: String = environment.appVersion; - - faLinkedin = faLinkedin - faGithub = faGithub } diff --git a/pollor.client/src/app/header/header.component.css b/pollor.client/src/app/header/header.component.css index e69de29..6add56e 100644 --- a/pollor.client/src/app/header/header.component.css +++ b/pollor.client/src/app/header/header.component.css @@ -0,0 +1,27 @@ +nav { + background-color: seagreen; +} + +a.nav-link.active { + background-color: white; + color: black !important; +} + +a.nav-link:hover { + background-color: white; + color: black !important; +} + +.dropdown-menu { + min-width: 200px; +} + +@media screen and (max-width:767px) { + .nav-pills > li { + width: 100%; + } + + .btn-group { + margin-left: auto; + } +} diff --git a/pollor.client/src/app/header/header.component.html b/pollor.client/src/app/header/header.component.html index f2f37b3..694308c 100644 --- a/pollor.client/src/app/header/header.component.html +++ b/pollor.client/src/app/header/header.component.html @@ -1,28 +1,101 @@ -