initial commit
All checks were successful
Build and Publish / Build Yale Access Backend (push) Successful in 28s
Build and Publish / Build Yale Access Frontend (push) Successful in 47s
Build and Publish / Push Yale Access Backend Docker Image (push) Successful in 9s
Build and Publish / Push Yale Access Frontend Docker Image (push) Successful in 10s

This commit is contained in:
Liam Pietralla 2025-01-10 08:37:18 +11:00
commit f577617b4d
80 changed files with 10113 additions and 0 deletions

133
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,133 @@
name: Build and Publish
on:
push:
branches:
- main
- feature/*
- fix/*
pull_request:
branches:
- main
jobs:
build-backend:
name: Build Yale Access Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
publish-backend:
name: Push Yale Access Backend Docker Image
runs-on: ubuntu-latest
needs: build-backend
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Docker Metadata
id: meta
uses: docker/metadata-action@v4
with:
images: liamsgit.dev/LiamPietralla/yale-user-access-backend
tags: |
type=raw,value=latest
labels: |
org.opencontainers.image.title=Yale Access Backend
org.opencontainers.image.description=Backend for Yale Access
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: liamsgit.dev
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
context: ./packages/backend
file: ./packages/backend/Dockerfile
build-frontend:
name: Build Yale Access Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 20.x
- name: Setup Yarn
run: npm install -g yarn
- name: Install dependencies
run: yarn install
- name: Build
run: yarn run build
publish-frontend:
name: Push Yale Access Frontend Docker Image
runs-on: ubuntu-latest
needs: build-frontend
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Docker Metadata
id: meta
uses: docker/metadata-action@v4
with:
images: liamsgit.dev/LiamPietralla/yale-user-access-frontend
tags: |
type=raw,value=latest
labels: |
org.opencontainers.image.title=Yale Access Frontend
org.opencontainers.image.description=Frontend for Yale Access
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: liamsgit.dev
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
context: ./packages/frontend
file: ./packages/frontend/Dockerfile

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.vs

83
README.md Normal file
View File

@ -0,0 +1,83 @@
# yale-access
Yale Access is a solution to allow passcodes to be added and removed to a Yale Smart Lock.
The solution is designed to be self-hosted, as it will need access to a ZWave.JS server to communicate with the lock.
## Prerequisites
A few pre-requisites are required for this application:
* A Yale Smart Lock (I have an Assure Lock SL)
* A ZWave.JS server
## Installation
Yale Access is best run as a Docker container, as this reduces almost all hosting compexity. The application will need to be hosted on the internal network on your home (though the application can be exposed to the internet if you wish).
### Preparing the lock
The lock will need to be paired with a ZWave.JS server, I suggest using ![ZWave.JS UI](https://github.com/zwave-js/zwave-js-ui). Once the lock is paired you will need to set a value for all the user codes you wish to use (this prevents null values being returned from the lock). To do this navigate to the lock in the ZWave.JS UI, and click the 'User Code v1' option. For each user code you are intending to use set the status to 'Available'.
### Docker
A sample docker compose file is provided in the repository. This can be used to get up and running quickly.
The appropriate environment variables will need to be set in the docker compose file. These are:
##### Frontend
* NUXT_PUBLIC_API_BASE_URL - The URL of the backend API
##### Backend
* LogLocation - The location to store logs, this path should also be mounted as a local volume, to ensure logs are not lost when the container is restarted.
* CorsAllowedOrigins - A comma separated list of allowed origins for CORS requests. This should be set to the URL of the frontend.
* Devices__YaleLockNodeId - The Node ID of the Yale Lock as found in the ZWave.JS interface.
* Codes__Home - The code ID to use as the 'home' code. This will have extra UI functionality, to prevent it from being updated or deleted accidentally.
* Codes__GuestCodeRangeStart - The start of the range of codes to use for guest codes. This should be set to the first code that is available for use.
* Codes__GuestCodeRangeCount - The number of codes to use for guest codes. This should be set to the number of codes that are available for use.
* ZWave_Url - The URL of the ZWave.JS server.
* ZWave_SchemeVersion - The scheme version of the ZWave.JS server. This should be set to the scheme version of the ZWave.JS server.
* Authentication_Password - The password to use for authentication. This should be set to a strong password.
* Twilio__AccountSid - The Account SID for the Twilio account to use for sending SMS messages.
* Twilio__AuthToken - The Auth Token for the Twilio account to use for sending SMS messages.
* Twilio__FromNumber - The number to send SMS messages from.
Once the environment variables have been set, the application can be started by running `docker-compose up -d` from the root of the compose file. To allow the application to be accessed from the internet, you will need to expose the application to the internet, the recommended setup would be a Nginx reverse proxy.
## Screenshots
### Login
<img src="./screenshots/login.png" alt="Login" width="300"/>
### Home Screen
<img src="./screenshots/home.png" alt="Home" width="300"/>
### Edit Code
<img src="./screenshots/edit.png" alt="Edit Code" width="300"/>
### Delete Code
<img src="./screenshots/delete.png" alt="Delete Code" width="300"/>
## Development
### Frontend
The frontend is built using Nuxt. To get started ensure you have node install (latest LTS is recommended), and run `yarn install` to install all dependencies.
To start the development server, run `yarn dev`.
### Backend
The backend is built using .NET 7. To get started ensure you have the .NET 7 SDK installed. Open the solution in Visual Studio and ensure that the appropriate user secrets are set. A `secrets.template.json` file is provided in the repository. This should be copied to `secrets.json` and populated with the appropriate values.
To start the backend, run `dotnet run` from the `YaleAccess.Api` directory (or use the Visual Studio debugger).
Note: you will need the ZWave.JS server running and configured to use the same scheme version as the backend for development unsless you are using the mock mode.
To enable the mock mode for development, set the `UseMockDevelopmentMode` flag to true in the `appsettings.Development.json` file. This will repalce teh ZWave.JS server with a mock implementation, which will allow you to test the application without a ZWave.JS server. This is handy for doing UI changes without having to have the lock available.

3
development/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
yale.db
yale.db-shm
yale.db-wal

View File

@ -0,0 +1,25 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*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

424
packages/backend/.gitignore vendored Normal file
View File

@ -0,0 +1,424 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# and the packages/backend and packages/frontend folders, since they contain our source code
!**/[Pp]ackages/backend/
!**/[Pp]ackages/frontend/
!**/[Pp]ackages/frontend-old/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
# Frontend
.DS_Store
node_modules/
/dist/
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sw*

View File

@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Serilog;
using System.Security.Claims;
using YaleAccess.Models;
namespace YaleAccess.Controllers
{
[ApiController]
[Route("api/[controller]")]
[EnableCors]
[Authorize]
public class AuthenticationController : ControllerBase
{
private readonly Models.Options.AuthenticationOptions _authenticationOptions;
public AuthenticationController(IOptions<Models.Options.AuthenticationOptions> authenticationOptions)
{
_authenticationOptions = authenticationOptions.Value;
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] string password)
{
try
{
// Check if the password is correct
if (password != _authenticationOptions.Password)
{
return Unauthorized(new ApiResponse("Incorrect password."));
}
// Log the user in
List<Claim> claims = new()
{
new Claim(ClaimTypes.Name, "YaleAccess")
};
ClaimsIdentity claimsIdentity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme);
ClaimsPrincipal claimsPrincipal = new(claimsIdentity);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal);
// Return the response
return Ok(new ApiResponse(true));
}
catch(Exception ex)
{
Log.Logger.Error(ex, "An error occurred logging in.");
return BadRequest(new ApiResponse("An error occurred logging in."));
}
}
[HttpPost("logout")]
[AllowAnonymous]
public async Task<IActionResult> Logout()
{
try
{
// Sign the user out
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok(new ApiResponse(true));
}
catch (Exception ex)
{
Log.Logger.Error(ex, "An error occured logging out.");
return BadRequest(new ApiResponse("An error occured logging out."));
}
}
}
}

View File

@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Serilog;
namespace YaleAccess.Controllers
{
[ApiController]
[Route("api/[controller]")]
[EnableCors]
[Authorize]
public class HealthController : ControllerBase
{
[HttpGet]
[AllowAnonymous]
public IActionResult Health()
{
Log.Logger.Information("Hit the health endpoint.");
return Ok("Service is healthy");
}
}
}

View File

@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Serilog;
using YaleAccess.Data;
using YaleAccess.Models;
namespace YaleAccess.Controllers
{
[ApiController]
[Route("api/[controller]")]
[EnableCors]
[Authorize]
public class PeopleController : ControllerBase
{
private readonly YaleContext _context;
public PeopleController(YaleContext context)
{
_context = context;
}
[HttpGet]
public async Task<IActionResult> GetPeople()
{
try
{
// Return all people
List<Person> people = await _context.People.ToListAsync();
return Ok(new ApiResponse(people));
}
catch (Exception ex)
{
Log.Logger.Error(ex, "An error occured retriving the people.");
return BadRequest(new ApiResponse("An error occured retriving the people."));
}
}
[HttpPost]
public async Task<IActionResult> CreatePerson([FromBody] Person person)
{
try
{
// Create a new person
Person newPerson = new() { Name = person.Name, PhoneNumber = person.PhoneNumber };
// Add the person
await _context.AddAsync(newPerson);
await _context.SaveChangesAsync();
// Return the newly created person
return Ok(new ApiResponse(newPerson));
}
catch (Exception ex)
{
Log.Logger.Error(ex, "An error occured creating the person.");
return BadRequest(new ApiResponse("An error occured creating the person."));
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeletePerson([FromRoute] int id)
{
try
{
// Ensure the person exists
Person person = await _context.People.FindAsync(id) ?? throw new Exception("Person not found.");
// Remove the person
_context.Remove(person);
await _context.SaveChangesAsync();
// Return the newly removed person
return Ok(new ApiResponse(person));
}
catch (Exception ex)
{
Log.Logger.Error(ex, "An error occured deletiong the person.");
return BadRequest(new ApiResponse("An error occured deletiong the person."));
}
}
}
}

View File

@ -0,0 +1,148 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Serilog;
using YaleAccess.Models;
using YaleAccess.Models.Options;
using YaleAccess.Services;
using YaleAccess.Services.Interfaces;
namespace YaleAccess.Controllers
{
[ApiController]
[Route("api/[controller]")]
[EnableCors]
[Authorize]
public class YaleController : ControllerBase
{
private readonly IYaleAccessor _yaleAccessor;
private readonly CodesOptions _codeOptions;
private readonly SMSService _smsService;
public YaleController(IYaleAccessor yaleAccessor, IOptions<CodesOptions> codeOptions, SMSService smsService)
{
_yaleAccessor = yaleAccessor;
_codeOptions = codeOptions.Value;
_smsService = smsService;
}
[HttpGet("codes")]
public async Task<IActionResult> GetUserCodes()
{
try
{
// Get the home code first
YaleUserCode homeCode = await _yaleAccessor.GetCodeInformationAsync(_codeOptions.Home);
homeCode.IsHome = true;
// Get the guest codes
List<YaleUserCode> guestCodes = new();
foreach (int code in Enumerable.Range(_codeOptions.GuestCodeRangeStart, _codeOptions.GuestCodeRangeCount))
{
guestCodes.Add(await _yaleAccessor.GetCodeInformationAsync(code));
}
// Add the home code to the list
guestCodes.Add(homeCode);
// Return the codes
return Ok(new ApiResponse(guestCodes));
}
catch(Exception ex)
{
Log.Logger.Error(ex, "An error occurred retriving the codes.");
return BadRequest(new ApiResponse("An error occurred retriving the codes."));
}
}
[HttpPost("code/{id}")]
public async Task<IActionResult> SetUserCode([FromRoute] int id, [FromBody] string newCode)
{
try
{
// First validate the user code
string validCode = YaleAccessor.ValidateCode(newCode);
if (validCode != string.Empty)
{
return BadRequest(new ApiResponse(validCode));
}
// Set the new code
bool result = await _yaleAccessor.SetUserCode(id, newCode);
// Return the result
if (result)
{
Log.Logger.Information("Updated code for user {id} to {code}", id, newCode);
return Ok(new ApiResponse(true));
}
else
{
Log.Logger.Information("Failed to update code for user {id} to {code}", id, newCode);
return BadRequest(new ApiResponse("An error occurred setting the code."));
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, "An error occurred setting the code.");
return BadRequest(new ApiResponse("An error occurred setting the code."));
}
}
[HttpPost("code/{id}/status")]
public async Task<IActionResult> SetUserCodeStatusAsAvailable([FromRoute] int id)
{
try
{
// First validate the user code
string validCode = YaleAccessor.ValidateClearCode(id, _codeOptions.Home);
if (validCode != string.Empty)
{
return BadRequest(new ApiResponse(validCode));
}
// Set the available status
bool result = await _yaleAccessor.SetCodeAsAvailable(id);
// Return the result
if (result)
{
Log.Logger.Information("Updated code status for user {id} to available", id);
return Ok(new ApiResponse(true));
}
else
{
Log.Logger.Information("Failed to update code status for user {id} to available", id);
return BadRequest(new ApiResponse("An error occurred setting the code status."));
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, "An error occurred setting the code status.");
return BadRequest(new ApiResponse("An error occurred setting the code status."));
}
}
[HttpPost("code/{id}/send")]
public async Task<IActionResult> SendUserCode([FromRoute] int id, [FromBody] string phoneNumber)
{
try
{
// Get the user code
YaleUserCode userCode = await _yaleAccessor.GetCodeInformationAsync(id);
// Send the code via SMS to the phone number
await _smsService.SendCodeViaSMSAsync(userCode.Code, phoneNumber);
// Return success
return Ok(new ApiResponse(true));
}
catch (Exception ex)
{
Log.Logger.Error(ex, "An error occurred sending the code.");
return BadRequest(new ApiResponse("An error occurred sending the code."));
}
}
}
}

View File

@ -0,0 +1,9 @@
namespace YaleAccess.Data
{
public class Person
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string PhoneNumber { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace YaleAccess.Data
{
public class YaleContext : DbContext
{
public YaleContext(DbContextOptions<YaleContext> options) : base(options) { }
public DbSet<Person> People { get; set; }
}
}

View File

@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["YaleAccess.csproj", "."]
RUN dotnet restore "./YaleAccess.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "YaleAccess.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "YaleAccess.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
RUN mkdir /yale-data
ENV ConnectionStrings__YaleContext="Data Source=/yale-data/yale-access.db"
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "YaleAccess.dll"]

View File

@ -0,0 +1,43 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using YaleAccess.Data;
#nullable disable
namespace YaleAccess.Migrations
{
[DbContext(typeof(YaleContext))]
[Migration("20250109010459_AddedPeople")]
partial class AddedPeople
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.20");
modelBuilder.Entity("YaleAccess.Data.Person", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("People");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YaleAccess.Migrations
{
/// <inheritdoc />
public partial class AddedPeople : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "People",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_People", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "People");
}
}
}

View File

@ -0,0 +1,40 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using YaleAccess.Data;
#nullable disable
namespace YaleAccess.Migrations
{
[DbContext(typeof(YaleContext))]
partial class YaleContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.20");
modelBuilder.Entity("YaleAccess.Data.Person", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("People");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,29 @@
namespace YaleAccess.Models
{
public class ApiResponse
{
public bool Success { get; set; }
public string? Error { get; set; }
public object? Data { get; set; }
/// <summary>
/// If passing in data only then the resonse is successful
/// </summary>
/// <param name="data"></param>
public ApiResponse(object data)
{
Success = true;
Data = data;
}
/// <summary>
/// If passing in an error then the response is not successful
/// </summary>
/// <param name="error"></param>
public ApiResponse(string error)
{
Success = false;
Error = error;
}
}
}

View File

@ -0,0 +1,9 @@
namespace YaleAccess.Models.Options
{
public class AuthenticationOptions
{
public const string Authentication = "Authentication";
public string Password { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,11 @@
namespace YaleAccess.Models.Options
{
public class CodesOptions
{
public const string Codes = "Codes";
public int Home { get; set; }
public int GuestCodeRangeStart { get; set; }
public int GuestCodeRangeCount { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace YaleAccess.Models.Options
{
public class DevicesOptions
{
public const string Devices = "Devices";
public int YaleLockNodeId { get; set; }
}
}

View File

@ -0,0 +1,12 @@
namespace YaleAccess.Models.Options
{
public class TwiloOptions
{
public const string Twilio = "Twilio";
public string AccountSid { get; set; } = string.Empty;
public string AuthToken { get; set; } = string.Empty;
public string FromNumber { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,10 @@
namespace YaleAccess.Models.Options
{
public class ZWaveOptions
{
public const string ZWave = "ZWave";
public string Url { get; set; } = null!;
public int SchemaVersion { get; set; }
}
}

View File

@ -0,0 +1,45 @@
namespace YaleAccess.Models
{
public class YaleUserCode
{
/// <summary>
/// The ID of the user code. When making requets to ZWave.JS API, this is the value to use.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The code / pin number for the user code. This may be null if the user code is not set or is not in use.
/// Even though its a string, it is a number and appropriate validation is required.
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// The current status of the user code.
/// </summary>
public UserCodeStatus Status { get; set; }
/// <summary>
/// Is true is this is the 'home' or normal user code for daily use.
/// </summary>
public bool IsHome { get; set; }
}
public enum UserCodeStatus
{
/// <summary>
/// This means the code is not being used, and can be assigned
/// </summary>
AVAILABLE = 0,
/// <summary>
/// This means that the code is in used, and cannot be assigned
/// </summary>
ENABLED = 1,
/// <summary>
/// Not sure what this means yet, perhaps it is related to schedules?
/// </summary>
DISABLED = 2
}
}

154
packages/backend/Program.cs Normal file
View File

@ -0,0 +1,154 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Serilog;
using Serilog.Events;
using YaleAccess.Data;
using YaleAccess.Models.Options;
using YaleAccess.Services;
using YaleAccess.Services.Interfaces;
var builder = WebApplication.CreateBuilder(args);
// Create the bootstraper logger
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
try
{
// Add services to the container.
builder.Services.AddControllers();
// Add the database context
builder.Services.AddDbContext<YaleContext>(options =>
{
options.UseSqlite(builder.Configuration.GetConnectionString(nameof(YaleContext)));
});
// Configure the options
builder.Services.Configure<AuthenticationOptions>(builder.Configuration.GetSection(AuthenticationOptions.Authentication));
builder.Services.Configure<CodesOptions>(builder.Configuration.GetSection(CodesOptions.Codes));
builder.Services.Configure<DevicesOptions>(builder.Configuration.GetSection(DevicesOptions.Devices));
builder.Services.Configure<ZWaveOptions>(builder.Configuration.GetSection(ZWaveOptions.ZWave));
builder.Services.Configure<TwiloOptions>(builder.Configuration.GetSection(TwiloOptions.Twilio));
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Get a copy of the configuration
IConfiguration configuration = builder.Configuration;
string logLocation = configuration["LogLocation"] ?? "Log.txt";
// Setup the application logger
Log.Logger = new LoggerConfiguration()
.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Error)
.WriteTo.File(logLocation, rollingInterval: RollingInterval.Day)
.CreateLogger();
// Configure the DI services
builder.Services.AddScoped<SMSService>();
// If the environment is development and the mock option is set to true, use the mock service
if (builder.Environment.IsDevelopment() && configuration.GetValue<bool>("UseMockDevelopmentMode"))
{
builder.Services.AddSingleton<MockYaleData>();
builder.Services.AddScoped<IYaleAccessor, MockYaleAccessor>();
}
else
{
builder.Services.AddScoped<IYaleAccessor, YaleAccessor>();
}
// Setup CORS
string[] corsAllowedOrigins = (configuration["CorsAllowedOrigins"] ?? "http://localhost:3000").Split(",") ;
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(corsAllowedOrigins)
.AllowCredentials()
.AllowAnyMethod()
.WithHeaders("Content-Type", "Authorization");
});
});
// Setup cookie authentication scheme
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// Set HTTP only to true to prevent XSS attacks
options.Cookie.HttpOnly = true;
// Set secure policy to always to prevent sending cookies over HTTP for production use, for development set to None
if (builder.Environment.IsDevelopment())
options.Cookie.SecurePolicy = CookieSecurePolicy.None;
else
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
// Ensure that the API will return a 401 Unauthorized instead of redirecting to the login page
options.AccessDeniedPath = string.Empty;
options.LoginPath = string.Empty;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
});
// Setup logging flow
builder.Host.UseSerilog();
var app = builder.Build();
// Create the database if it doesn't exist
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var context = services.GetRequiredService<YaleContext>();
context.Database.Migrate();
}
// Configure the forwarded headers middleware
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
catch (Exception ex)
{
// Ignore host aborted exceptions caused by build checks
if (ex is not HostAbortedException)
{
Log.Fatal(ex, "Host terminated unexpectedly");
throw;
}
}
finally
{
Log.CloseAndFlush();
}

View File

@ -0,0 +1,51 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5051"
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7069;http://localhost:5051"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Docker": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"environmentVariables": {
"ASPNETCORE_URLS": "https://+:443;http://+:80"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:62005",
"sslPort": 44335
}
}
}

View File

@ -0,0 +1,11 @@
using YaleAccess.Models;
namespace YaleAccess.Services.Interfaces
{
public interface IYaleAccessor
{
public Task<YaleUserCode> GetCodeInformationAsync(int userCodeId);
public Task<bool> SetUserCode(int userCodeId, string code);
public Task<bool> SetCodeAsAvailable(int userCode);
}
}

View File

@ -0,0 +1,30 @@
using YaleAccess.Models;
using YaleAccess.Services.Interfaces;
namespace YaleAccess.Services
{
public class MockYaleAccessor : IYaleAccessor
{
private readonly MockYaleData _data;
public MockYaleAccessor(MockYaleData data)
{
_data = data;
}
public Task<YaleUserCode> GetCodeInformationAsync(int userCodeId)
{
return Task.FromResult(_data.GetUserCode(userCodeId));
}
public Task<bool> SetCodeAsAvailable(int userCode)
{
return Task.FromResult(_data.SetUserCodeAsAvailable(userCode));
}
public Task<bool> SetUserCode(int userCodeId, string code)
{
return Task.FromResult(_data.SetUserCode(userCodeId, code));
}
}
}

View File

@ -0,0 +1,78 @@
using YaleAccess.Models;
namespace YaleAccess.Services
{
public class MockYaleData
{
public List<YaleUserCode> UserCodes { get; set; } = new();
public MockYaleData()
{
// Create the home code
YaleUserCode homeCode = new()
{
Id = 1,
Code = "1234",
IsHome = true,
Status = UserCodeStatus.ENABLED
};
// Create 5 guest codes
List<YaleUserCode> guestCodes = new();
foreach (int code in Enumerable.Range(2, 5))
{
guestCodes.Add(new YaleUserCode()
{
Id = code,
Code = "",
IsHome = false,
Status = UserCodeStatus.AVAILABLE
});
}
// Add the home code to the list
guestCodes.Add(homeCode);
// Set the user codes
UserCodes = guestCodes;
}
public YaleUserCode GetUserCode(int id)
{
return UserCodes.First(x => x.Id == id);
}
public bool SetUserCode(int id, string code)
{
// Get the user code
YaleUserCode userCode = GetUserCode(id);
// Set the code
userCode.Code = code;
// Update code status
userCode.Status = UserCodeStatus.ENABLED;
// Update the user code in the list
UserCodes[UserCodes.IndexOf(userCode)] = userCode;
// Return true to indicate success
return true;
}
public bool SetUserCodeAsAvailable(int id)
{
// Get the user code
YaleUserCode userCode = GetUserCode(id);
// Set the code
userCode.Status = UserCodeStatus.AVAILABLE;
// Update the user code in the list
UserCodes[UserCodes.IndexOf(userCode)] = userCode;
// Return true to indicate success
return true;
}
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.Extensions.Options;
using Serilog;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using YaleAccess.Models.Options;
namespace YaleAccess.Services
{
public class SMSService
{
private readonly TwiloOptions _twiloOptions;
public SMSService(IOptions<TwiloOptions> twiloOptions)
{
_twiloOptions = twiloOptions.Value;
}
public async Task SendCodeViaSMSAsync(string code, string phoneNumber)
{
// Create a Twilio client
TwilioClient.Init(_twiloOptions.AccountSid, _twiloOptions.AuthToken);
// Send the message
var message = await MessageResource.CreateAsync(
body: $"{_twiloOptions.Message} {code}",
from: new Twilio.Types.PhoneNumber(_twiloOptions.FromNumber),
to: new Twilio.Types.PhoneNumber(phoneNumber)
);
// Log the message
Log.Logger.Information("SMS sent to {PhoneNumber} with message SID {MessageSid}.", phoneNumber, message.Sid);
Log.Logger.Debug("SMS message: {MessageBody}", message.Body);
}
}
}

View File

@ -0,0 +1,195 @@
using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
using Serilog;
using YaleAccess.Models;
using YaleAccess.Models.Options;
using YaleAccess.Services.Interfaces;
using ZWaveJS.NET;
using ZWaveOptions = YaleAccess.Models.Options.ZWaveOptions;
namespace YaleAccess.Services
{
public class YaleAccessor : IYaleAccessor, IDisposable
{
#region Dispose Logic
private bool disposedValue;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
driver?.Destroy();
}
disposedValue = true;
driver = null;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion Dispose Logic
private Driver? driver = null;
private readonly ZWaveNode lockNode = null!;
public YaleAccessor(IOptions<ZWaveOptions> zwave, IOptions<DevicesOptions> device)
{
// Retrive options from configuration
ZWaveOptions zwaveOptions = zwave.Value;
DevicesOptions devicesOptions = device.Value;
// Create a new driver instance
driver = new Driver(new Uri(zwaveOptions.Url), zwaveOptions.SchemaVersion);
// Start the driver
driver.Start();
// Flag to indicate if the driver is ready to use
bool isReady = false;
// Subscribe to the driver ready event
driver.DriverReady += () =>
{
isReady = true;
};
driver.StartUpError += (message) =>
{
throw new Exception(message);
};
// Wait for the driver to be ready
while (!isReady)
{
Thread.Sleep(100);
}
// Get the lock node from the driver
lockNode = driver.Controller.Nodes.Get(devicesOptions.YaleLockNodeId);
}
public async Task<YaleUserCode> GetCodeInformationAsync(int userCodeId)
{
// Setup the two tasks to get the values we need
CMDResult status = await lockNode.GetValue(GetUserStatusValue(userCodeId));
CMDResult code = await lockNode.GetValue(GetUserCodeValue(userCodeId));
// Covert the result to a YaleUserCode object
return new YaleUserCode()
{
Id = userCodeId,
Code = GetUserCodeValue(code),
Status = GetUserStatusValue(status),
IsHome = false
};
}
public async Task<bool> SetUserCode(int userCodeId, string code)
{
// Setup the set value task
CMDResult result = await lockNode.SetValue(GetUserCodeValue(userCodeId), code);
// If the result is not successful log the message
if (!result.Success)
{
Log.Logger.Error("Failed to set user code {@userCodeId} to {@code}. Error message: {message}", userCodeId, code, result.Message);
}
// Return the result
return result.Success;
}
public async Task<bool> SetCodeAsAvailable(int userCode)
{
// Setup the set value task
CMDResult result = await lockNode.SetValue(GetUserStatusValue(userCode), (int)UserCodeStatus.AVAILABLE);
// If the result is not successful log the message
if (!result.Success)
{
Log.Logger.Error("Failed to set user code {@userCode} to available status. Error message: {message}", userCode, result.Message);
}
// Return the result
return result.Success;
}
private static UserCodeStatus GetUserStatusValue(CMDResult result)
{
// Parse the payload as a JSON object
JObject payloadObject = (JObject)result.ResultPayload;
// Return the value property
return (UserCodeStatus)payloadObject.GetValue("value")!.ToObject<int>();
}
private static string GetUserCodeValue(CMDResult result)
{
// Parse the payload as a JSON object
JObject payloadObject = (JObject)result.ResultPayload;
// Return the value property
return payloadObject.GetValue("value")!.ToString();
}
private static ValueID GetUserStatusValue(int userCodeId)
{
return new ValueID()
{
commandClass = 99,
endpoint = 0,
property = "userIdStatus",
propertyKey = userCodeId
};
}
private static ValueID GetUserCodeValue(int userCodeId)
{
return new ValueID()
{
commandClass = 99,
endpoint = 0,
property = "userCode",
propertyKey = userCodeId
};
}
public static string ValidateCode(string newCode)
{
// The code must be between 4 and 6 digits
if (newCode.Length < 4 || newCode.Length > 6)
{
return "The code must be between 4 and 6 digits.";
}
// The code must be numeric
if (!int.TryParse(newCode, out int _))
{
return "The code must be numeric.";
}
// Otherwise, the code is valid, return an empty string
return string.Empty;
}
public static string ValidateClearCode(int codeId, int homeCodeId)
{
// If the code is the home code, return an error message
if (codeId == homeCodeId)
{
return "The home code cannot be cleared.";
}
return string.Empty;
}
}
}

View File

@ -0,0 +1,25 @@
{
"LogLocation": "<Location>/Log.txt", // Location of the log file
"CorsAllowedOrigins": "http://localhost:5173", // The URL of the frontend SPA
"Devices": {
"YaleLockNodeId": "?" // The yale lock node id from your ZWave Network
},
"Codes": {
"Home": 1, // Home user code
"GuestCodeRangeStart": 2, // Guest or user codes start ID
"GuestCodeRangeCount": 5 // Guest or user codes count
},
"ZWave": {
"Url": "ws://localhost:3000", // The URL of the ZWave Server
"SchemaVersion": "?" // The schema version of your ZWave Network
},
"Authentication": {
"Password": "<Password>" // Password to access the YaleAccess Portal
},
"Twilio": {
"AccountSid": "<SID>",
"AuthToken": "<Auth>",
"FromNumber": "<From Number>",
"Message": "<Message>"
}
}

View File

@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>83aa9238-2d6b-483c-b60d-886f32d17532</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Templates\**" />
<Content Remove="Templates\**" />
<EmbeddedResource Remove="Templates\**" />
<None Remove="Templates\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.20">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Semver" Version="2.3.0" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="System.Threading.Channels" Version="5.0.0" />
<PackageReference Include="Twilio" Version="7.8.0" />
<PackageReference Include="Websocket.Client" Version="4.6.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="lib\" />
</ItemGroup>
<ItemGroup>
<Reference Include="ZWaveJS.NET">
<HintPath>lib\ZWaveJS.NET.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.34221.43
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YaleAccess", "YaleAccess.csproj", "{BC7CD738-F262-484A-9C91-CED5BBBAD2A9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionItems", "{A93B8970-E2F5-410D-9587-72572C991A61}"
ProjectSection(SolutionItems) = preProject
..\..\README.md = ..\..\README.md
Templates\secrets.template.json = Templates\secrets.template.json
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BC7CD738-F262-484A-9C91-CED5BBBAD2A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BC7CD738-F262-484A-9C91-CED5BBBAD2A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BC7CD738-F262-484A-9C91-CED5BBBAD2A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BC7CD738-F262-484A-9C91-CED5BBBAD2A9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6CB96495-C495-41AF-BFAB-329DFC3E350C}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,6 @@
{
"UseMockDevelopmentMode": true,
"ConnectionStrings": {
"YaleContext": "Data Source=../../development/yale.db"
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

Binary file not shown.

View File

@ -0,0 +1,25 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*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

24
packages/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View File

@ -0,0 +1,13 @@
FROM node:lts-alpine as build
WORKDIR /src
COPY ["package.json", "."]
COPY ["yarn.lock", "."]
RUN yarn install
COPY . .
RUN yarn build
FROM node:lts-alpine as production
COPY --from=build /src/.output /app
WORKDIR /app
EXPOSE 3000
CMD ["node", "server/index.mjs"]

13
packages/frontend/app.vue Normal file
View File

@ -0,0 +1,13 @@
<script setup lang="ts">
useHead({
bodyAttrs: {
class: 'bg-black'
}
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
const runtimeConfig = useRuntimeConfig();
const healthResult = ref<string>("Fetching health check...");
onMounted(async () => {
// Send a request to the API Health Check endpoint
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/Health`);
// Extract the response payload (string result)
const result = await response.text();
// Set the result
healthResult.value = result;
})
</script>
<template>
<div class="text-slate-300">
<h1>Health Check</h1>
<p>{{ healthResult }}</p>
</div>
</template>

View File

@ -0,0 +1,67 @@
<script setup lang='ts'>
import { ref } from 'vue';
import { type ApiResponse } from '~/types/api-response';
const password = ref('');
const passwordError = ref('');
const authenticated = useCookie<boolean>('authenticated');
const runtimeConfig = useRuntimeConfig();
const handleLogin = async (event: Event) => {
// Prevent default form submission
event.preventDefault();
// Reset the error
passwordError.value = '';
// Send a request to the api to login
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/Authentication/login`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: `"${password.value}"`
});
// Parse the response
const apiResponse = await response.json() as ApiResponse<boolean>;
if (apiResponse.success) {
// If the response was successful, login and redirect to the home page
authenticated.value = true;
navigateTo('/');
} else {
// Otherwise, set the error
passwordError.value = apiResponse.error ?? 'Unknown error';
}
}
</script>
<template>
<div class="bg-zinc-800 text-slate-300 text-center w-full md:w-96 rounded-md">
<h1 class="text-7xl mt-5">Yale</h1>
<div>
<h4 class="hr text-2xl mt-3">User Access</h4>
</div>
<form @submit="handleLogin">
<YaleFormInput type="password" v-model="password" placeholder="Password" class="block mt-10 w-5/6 mx-auto" />
<p class="mt-3 w-5/6 mx-auto text-red-600" v-if="passwordError">{{ passwordError }}</p>
<YaleButton type="submit" class="mb-8 mt-3 w-5/6 mx-auto ">Login</YaleButton>
</form>
</div>
</template>
<style>
.hr {
display: inline-block;
}
.hr::after {
content: '';
display: block;
border-top: 2px solid theme('colors.slate.300');
margin-top: .1rem;
}
</style>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
const emit = defineEmits<{
(e: "close"): void
}>();
const handleClick = () => {
emit("close");
}
// If clicking outside the modal, close it
document.addEventListener("click", (event) => {
if (event.target === document.querySelector(".fixed")) {
handleClick();
}
});
</script>
<template>
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center">
<div class="bg-zinc-800 rounded-md min-w-96">
<div class="p-6 border-b border-slate-300">
<h3 class="text-md font-bold flex justify-between items-center">
<slot name="title"></slot>
<YaleButton type="button" @click="handleClick">
<IconXMark />
</YaleButton>
</h3>
</div>
<div class="p-6 border-b border-slate-300">
<slot></slot>
</div>
<div class="p-6">
<slot name="footer"></slot>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,7 @@
<template>
<nav class="w-full bg-zinc-800 p-4 mb-4 flex gap-5 items-center">
<h1 class="text-slate-300 font-bold text-2xl inline-block">Yale | User Access</h1>
<NuxtLink to="/" class="text-slate-300 hover:text-slate-100">Home</NuxtLink>
<NuxtLink to="/people" class="text-slate-300 hover:text-slate-100">People</NuxtLink>
</nav>
</template>

View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import { type Person } from '@/types/yale';
import { onMounted, ref } from 'vue';
import type { ApiResponse } from '~/types/api-response';
const people = ref([] as Person[]);
const person = ref({ id: 0, name: '', phoneNumber: '' } as Person);
const loading = ref(true);
const runtimeConfig = useRuntimeConfig();
const addModal = ref(false);
// When the component is mounted, load the people
onMounted(async () => {
await loadPeople();
loading.value = false;
});
const loadPeople = async () => {
// Send a request to the api to get the people
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/People`, { credentials: 'include' });
// Parse the response
const apiResponse = await response.json() as ApiResponse<Person[]>;
// Set the people
people.value = apiResponse.data || [];
}
const handleAddPerson = () => {
addModal.value = true;
}
const handleSavePerson = async () => {
// Validate the person fields
if (!person.value.name || !person.value.phoneNumber) {
alert('Name and phone number are required');
return;
}
// Set the loading status
loading.value = true;
// Send a request to the api to save the person
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/People`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(person.value)
});
// Parse the response
const apiResponse = await response.json() as ApiResponse<Person>;
if (apiResponse.success) {
// Add the new person to the list
people.value.push(apiResponse.data!);
addModal.value = false;
} else {
alert(apiResponse.error ?? 'An error occurred');
}
// Close the modal and reset the person
loading.value = false;
person.value = { id: 0, name: '', phoneNumber: '' };
// Set the loading status
loading.value = false;
}
const handleDeletePerson = async (id: number) => {
// Set the loading status
loading.value = true;
// Send a request to the api to delete the person
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/People/${id}`, {
method: 'DELETE',
credentials: 'include'
});
// Parse the response
const apiResponse = await response.json() as ApiResponse<Person>;
if (apiResponse.success) {
// Remove the person from the list
const index = people.value.findIndex(x => x.id === id);
people.value.splice(index, 1);
} else {
alert(apiResponse.error ?? 'An error occurred');
}
// Set the loading status
loading.value = false;
}
</script>
<template>
<!-- Handle loading state -->
<div v-if="loading">Loading...</div>
<template v-else>
<div class="text-end my-2">
<YaleButton type="button" @click="handleAddPerson">Add Person</YaleButton>
</div>
<table class="w-full text-left">
<tr>
<th scope="col">Person ID</th>
<th scope="col">Name</th>
<th scope="col">Phone Number</th>
<th scope="col">Actions</th>
</tr>
<PeopleListRow v-for="person in people" :key="person.id" :person="person" @delete-person="handleDeletePerson" />
</table>
</template>
<!-- Add modal -->
<Modal v-if="addModal" @close="addModal = false">
<template #title>
Add Person
</template>
<template #default>
<div class="flex flex-col space-y-2">
<YaleFormInput v-model="person.name" type="text" placeholder="Name" class="block w-full" />
<YaleFormInput v-model="person.phoneNumber" type="text" placeholder="Phone" class="block w-full" />
<div class="flex justify-end">
<YaleButton type="button" @click="handleSavePerson">Save</YaleButton>
</div>
</div>
</template>
</Modal>
</template>

View File

@ -0,0 +1,34 @@
<script setup lang='ts'>
import { type Person } from '~/types/yale';
import { type PropType } from 'vue';
const props = defineProps({
person: {
type: Object as PropType<Person>,
required: true
}
});
const emit = defineEmits<{
(e: "delete-person", id: number): void
}>();
// Handle the clear code button click
const handleDeletePersonClick = () => {
// Emit the event to the parent component
emit("delete-person", props.person.id);
}
</script>
<template>
<tr scope="row">
<td>{{ person.id }}</td>
<td>{{ person.name }}</td>
<td>{{ person.phoneNumber }}</td>
<td class="flex">
<YaleButton type="button" @click="handleDeletePersonClick">
<IconTrash />
</YaleButton>
</td>
</tr>
</template>

View File

@ -0,0 +1,300 @@
<script setup lang='ts'>
import type { ApiResponse } from '@/types/api-response';
import UserCodeListRow from './UserCodeListRow.vue';
import { onMounted, ref } from 'vue';
import { UserCodeStatus, type Person, type YaleUserCode } from '@/types/yale';
const userCodes = ref([] as YaleUserCode[]);
const userCode = ref({} as YaleUserCode);
const people = ref([] as { label: string, value: string }[]);
const personNumber = ref('');
const loading = ref(true);
const runtimeConfig = useRuntimeConfig();
// Modals
const showModal = ref(false);
const editModal = ref(false);
const clearModal = ref(false);
const sendModal = ref(false);
// Form Fields
const newCode = ref('');
const newCodeError = ref('');
// When the component is mounted, load the user codes
onMounted(async () => {
// Load the user codes and set the loading status when complete
await loadUserCodes();
await loadPeople();
loading.value = false;
});
const loadUserCodes = async () => {
// Send a request to the api to get the user codes
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/Yale/codes`, { credentials: 'include' });
// Parse the response
const apiResponse = await response.json() as ApiResponse<YaleUserCode[]>;
// Set the user codes by id
userCodes.value = apiResponse.data?.sort((a, b) => a.id - b.id) || [];
}
const loadPeople = async () => {
// Send a request to the api to get the people
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/People`, { credentials: 'include' });
// Parse the response
const apiResponse = await response.json() as ApiResponse<Person[]>;
// Set the people for drop down
people.value = apiResponse.data?.map(x => ({ label: x.name, value: x.phoneNumber })) || [];
}
const confirmUpdateCode = async (id: number, code: string) => {
// Reset the error
newCodeError.value = '';
// Validate the new code first
if (!/^\d{4,6}$/.test(code)) {
newCodeError.value = 'Code must be 4, 5 or 6 digits long';
return;
}
// Set the loading status
loading.value = true;
// Send a request to the api, the body will just be the new code
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/Yale/code/${id}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: `"${code}"`
});
// Parse the result
const apiResponse = await response.json() as ApiResponse<boolean>;
if (apiResponse.success) {
// If the response was successful update the code in the list
const index = userCodes.value.findIndex(x => x.id === id);
userCodes.value[index].code = code;
userCodes.value[index].status = UserCodeStatus.ENABLED;
} else {
// Otherwise display an error to the user.
alert(apiResponse.error ?? 'Unknown error');
}
// Reset the new code
newCode.value = '';
// Close the edit modal
editModal.value = false;
// Set the loading status
loading.value = false;
}
const confirmClearCode = async (id: number) => {
// Set the loading status
loading.value = true;
// Send a request to the api
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/Yale/code/${id}/status`, {
method: 'POST',
credentials: 'include'
});
// Parse the response
const apiResponse = await response.json() as ApiResponse<boolean>;
if (apiResponse.success) {
// If the response was successful set the code to available in the list
const index = userCodes.value.findIndex(x => x.id === id);
userCodes.value[index].code = '';
userCodes.value[index].status = UserCodeStatus.AVAILABLE;
} else {
// Otherwise display an error to the user.
alert(apiResponse.error ?? 'Unknown error');
}
// Close the clear modal
clearModal.value = false;
// Set the loading status
loading.value = false;
}
const handleShowCode = (id: number) => {
// Get the user code to show
const code = userCodes.value.find(x => x.id === id);
// If the code is not found then show an error to the user
if (!code) {
alert('Code not found');
return;
}
// Set the user code to show
userCode.value = code;
// Show the show-modal
showModal.value = true;
}
const handleUpdateCode = (id: number) => {
// Get the user code to update
const code = userCodes.value.find(x => x.id === id);
// If the code is not found then show an error to the user
if (!code) {
alert('Code not found');
return;
}
// Set the user code to update
userCode.value = code;
// Show the edit-modal
editModal.value = true;
}
const handleClearCode = (id: number) => {
// Get the user code to clear
const code = userCodes.value.find(x => x.id === id);
// If the code is not found then show an error to the user
if (!code) {
alert('Code not found');
return;
}
// Set the user code to clear
userCode.value = code;
// Show the clear-modal
clearModal.value = true;
}
const handleSendCode = (id: number) => {
// Get the user code to send
const code = userCodes.value.find(x => x.id === id);
// If the code is not found then show an error to the user
if (!code) {
alert('Code not found');
return;
}
// Set the user code
userCode.value = code;
// Show the send modal
sendModal.value = true;
}
const confirmSendCode = async (id: number) => {
// Set the loading status
loading.value = true;
if (!personNumber.value) {
alert('Please select a person to send the code to');
loading.value = false;
return;
}
// Send a request to the api to send the code
const response = await fetch(`${runtimeConfig.public.apiBaseUrl}/api/Yale/code/${id}/send`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: `"${personNumber.value}"`
});
// Parse the response
const apiResponse = await response.json() as ApiResponse<boolean>;
if (apiResponse.success) {
alert('Code sent successfully');
} else {
alert(apiResponse.error ?? 'Unknown error');
}
// Close the clear modal
sendModal.value = false;
// Clear the person number and user code
personNumber.value = '';
userCode.value = {} as YaleUserCode;
// Set the loading status
loading.value = false;
}
</script>
<template>
<!-- Handle loading state -->
<div v-if="loading">Loading...</div>
<table v-else class="w-full text-left">
<tr>
<th scope="col">Code ID</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
<UserCodeListRow v-for="userCode in userCodes" :key="userCode.id" :user-code="userCode"
@update-code="handleUpdateCode" @clear-code="handleClearCode" @show-code="handleShowCode"
@send-code="handleSendCode" />
</table>
<!-- Show modal -->
<Modal v-if="showModal" @close="showModal = false">
<template #title>
User Code # {{ userCode.id }}
</template>
<template #default>
Code: {{ userCode.code }}
</template>
</Modal>
<!-- Edit modal -->
<Modal v-if="editModal" @close="editModal = false">
<template #title>
Edit User Code # {{ userCode.id }}
</template>
<template #default>
<YaleFormInput v-model="newCode" type="text" placeholder="New Code" class="block w-full" />
<p class="text-red-600 mt-3" v-if="newCodeError">{{ newCodeError }}</p>
<YaleButton @click="confirmUpdateCode(userCode.id, newCode)" class="w-full mt-3">Update Code</YaleButton>
</template>
<template #footer v-if="userCode.isHome">
<p class="text-red-600 mt-3">You are about to edit the home code, are you sure?</p>
</template>
</Modal>
<!-- Clear modal -->
<Modal v-if="clearModal" @close="clearModal = false">
<template #title>
Delete User Code # {{ userCode.id }}
</template>
<template #default>
<p>Are you sure you want to delete this code?</p>
<YaleButton @click="confirmClearCode(userCode.id)" class="w-full mt-3">Delete Code</YaleButton>
</template>
</Modal>
<!-- Send modal -->
<Modal v-if="sendModal" @close="sendModal = false">
<template #title>
Send User Code # {{ userCode.id }}
</template>
<template #default>
<YaleFormSelect v-model="personNumber" :options="people" class="block w-full"
placeholder="Select a Person" />
<YaleButton @click="confirmSendCode(userCode.id)" class="w-full mt-3">Send Code</YaleButton>
</template>
</Modal>
</template>

View File

@ -0,0 +1,82 @@
<script setup lang='ts'>
import { UserCodeStatus, type YaleUserCode } from '~/types/yale';
import { type PropType } from 'vue';
const props = defineProps({
userCode: {
type: Object as PropType<YaleUserCode>,
required: true
}
});
const emit = defineEmits<{
(e: "show-code", id: number): void
(e: "update-code", id: number): void
(e: "clear-code", id: number): void
(e: "send-code", id: number): void
}>();
const handleShowCodeClick = () => {
// Emit the event to the parent component to handle
emit("show-code", props.userCode.id);
}
// Handle the update code button click
const handleUpdateCodeClick = () => {
// Emit the event to the parent component
emit("update-code", props.userCode.id);
}
// Handle the clear code button click
const handleClearCodeClick = () => {
// Emit the event to the parent component
emit("clear-code", props.userCode.id);
}
// Handle the send code button click
const handleSendCodeClick = () => {
// Emit the event to the parent component
emit("send-code", props.userCode.id);
}
const userCodeStatusDisplay = (status: UserCodeStatus): string => {
switch (status) {
case UserCodeStatus.AVAILABLE:
return 'Available';
case UserCodeStatus.ENABLED:
return 'Enabled';
case UserCodeStatus.DISABLED:
return 'Disabled';
default:
return 'Unknown';
}
}
</script>
<template>
<tr scope="row">
<td>
<template v-if="userCode.isHome">
{{ userCode.id }} (<IconHome />)
</template>
<template v-else>
{{ userCode.id }}
</template>
</td>
<td>{{ userCodeStatusDisplay(userCode.status) }}</td>
<td class="flex">
<YaleButton type="button" @click="handleShowCodeClick" :disabled="props.userCode.status !== UserCodeStatus.ENABLED">
<IconEye />
</YaleButton>
<YaleButton type="button" class="ml-2" @click="handleUpdateCodeClick">
<IconPencil />
</YaleButton>
<YaleButton type="button" class="ml-2" @click="handleSendCodeClick" :disabled="props.userCode.status !== UserCodeStatus.ENABLED" v-if="!props.userCode.isHome">
<IconSend />
</YaleButton>
<YaleButton type="button" class="ml-2" @click="handleClearCodeClick" :disabled="props.userCode.status !== UserCodeStatus.ENABLED" v-if="!props.userCode.isHome">
<IconTrash />
</YaleButton>
</td>
</tr>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
</template>

View File

@ -0,0 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round"
d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-send"
viewBox="0 0 24 24">
<path
d="M15.854.146a.5.5 0 0 1 .11.54l-5.819 14.547a.75.75 0 0 1-1.329.124l-3.178-4.995L.643 7.184a.75.75 0 0 1 .124-1.33L15.314.037a.5.5 0 0 1 .54.11ZM6.636 10.07l2.761 4.338L14.13 2.576zm6.787-8.201L1.591 6.602l4.339 2.76z" />
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</template>

View File

@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { HTMLButtonTypes } from '~/types/html-input-types';
interface ButtonProps {
type?: HTMLButtonTypes
disabled?: boolean
}
const props = withDefaults(defineProps<ButtonProps>(), {
type: 'button',
disabled: false
})
const emit = defineEmits([
'click'
])
const handleClick = () => {
emit('click');
}
</script>
<template>
<button :type="props.type" :disabled="props.disabled" class="bg-stone-950 hover:bg-stone-900 p-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
@click="handleClick">
<slot></slot>
</button>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLInputTypes } from '~/types/html-input-types';
const props = defineProps<{
type: HTMLInputTypes
placeholder: string
modelValue: string
}>();
const emit = defineEmits([
'update:modelValue'
])
const handleInput = (event: Event) => {
// If input is a text input, emit the value
if (props.type === 'text' || props.type === 'password') {
emit('update:modelValue', (event.target as HTMLInputElement).value);
}
}
</script>
<template>
<input class="bg-zinc-900 p-2 rounded-md" :type="props.type"
:value="props.modelValue" :placeholder="props.placeholder" @input="handleInput">
</template>~/types/html-input-types

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
const props = defineProps<{
options: Array<{ label: string; value: string }>,
placeholder: string,
modelValue: string
}>();
const emit = defineEmits([
'update:modelValue'
]);
const handleSelect = (event: Event) => {
emit('update:modelValue', (event.target as HTMLSelectElement).value);
};
</script>
<template>
<select class="bg-zinc-900 p-2 rounded-md" :value="props.modelValue" @change="handleSelect">
<!-- Placeholder as the first option -->
<option value="" disabled selected>{{ props.placeholder }}</option>
<!-- Render options dynamically -->
<option v-for="option in props.options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
const props = defineProps<{
heading?: string;
}>();
</script>
<template>
<h2 v-if="props.heading" class="hr text-lg font-bold mb-2">{{ props.heading }}</h2>
<div class="rounded-md bg-zinc-800 p-2">
<slot></slot>
</div>
</template>
<style>
.hr {
display: inline-block;
}
.hr::after {
content: '';
display: block;
border-top: 2px solid theme('colors.slate.300');
margin-top: .1rem;
}
</style>

View File

@ -0,0 +1,5 @@
<template>
<div class="text-slate-300">
<slot />
</div>
</template>

View File

@ -0,0 +1,6 @@
<template>
<Navbar />
<div class="text-slate-300 container mx-auto px-4">
<slot />
</div>
</template>

View File

@ -0,0 +1,9 @@
const anonymousRoutes = ['/login', '/health'];
export default defineNuxtRouteMiddleware((to, from) => {
// If the route is not the login page and the user is not logged in then redirect to login
const authenticated = useCookie<boolean>('authenticated');
if (!anonymousRoutes.includes(to.path) && !(authenticated.value) ) {
return navigateTo('/login');
}
})

View File

@ -0,0 +1,10 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [ '@nuxtjs/tailwindcss' ],
devtools: { enabled: true },
runtimeConfig: {
public: {
apiBaseUrl: 'https://localhost:7069'
}
}
})

View File

@ -0,0 +1,20 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"@nuxtjs/tailwindcss": "^6.11.3",
"nuxt": "^3.9.1",
"vue": "^3.4.10",
"vue-router": "^4.2.5"
},
"dependencies": {}
}

View File

@ -0,0 +1,3 @@
<template>
<HealthCheck />
</template>

View File

@ -0,0 +1,5 @@
<template>
<YalePanel heading="User Code List">
<UserCodeList />
</YalePanel>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
definePageMeta({
layout: 'blank'
})
</script>
<template>
<div class="h-screen flex items-center justify-center w-11/12 md:w-auto mx-auto">
<LoginForm />
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<YalePanel title="People">
<PeopleList />
</YalePanel>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

View File

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View File

@ -0,0 +1,5 @@
export type ApiResponse<T> = {
success: boolean;
error?: string;
data?: T;
}

View File

@ -0,0 +1,3 @@
export type HTMLInputTypes = 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local' | 'month' | 'week' | 'color';
export type HTMLButtonTypes = 'submit' | 'reset' | 'button';

View File

@ -0,0 +1,18 @@
export type YaleUserCode = {
id: number;
code: string;
status: UserCodeStatus;
isHome: boolean;
}
export enum UserCodeStatus {
AVAILABLE = 0,
ENABLED = 1,
DISABLED = 2
}
export type Person = {
id: number;
name: string;
phoneNumber: string;
}

7096
packages/frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

37
sample.compose.yml Normal file
View File

@ -0,0 +1,37 @@
services:
frontend:
container_name: yale-access-frontend
image: liamp1/yale-access-frontend:latest
restart: unless-stopped
ports: 5015:3000
environment:
- NUXT_PUBLIC_API_BASE_URL=http://<computer-ip>:5016
backend:
container_name: yale-access-backend
image: liamp1/yale-access-backend:latest
restart: unless-stopped
ports: 5016:80
environment:
- LogLocation=/log/log.txt
- CorsAllowedOrigins=http://<computer-ip>:5015
- Devices__YaleLockNodeId=<node-id>
- Codes__Home=<code-id>
- Codes__GuestCodeRangeStart=<code-id>
- Codes__GuestCodeRangeCount=<count>
- ZWave__Url=http://<z-wave-ip>:<z-wave-port>
- ZWave__SchemaVersion=<z-wave-schema>
- Authentication__Password=<password>
- Twilio__AccountSid=<twilio-account-sid>
- Twilio__AuthToken=<twilio-auth-token>
- Twilio__FromNumber=<twilio-from-number>
- Twilio__Message=<twilio-message>
volumes:
- ./log:/log
- ./data:/yale-data

BIN
screenshots/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
screenshots/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
screenshots/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
screenshots/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB