WIP: Begins work on login / signup, adds user database models, authentication needs implemented.
This commit is contained in:
@@ -6051,6 +6051,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mt-8 {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
}
|
||||
@@ -6133,6 +6136,12 @@
|
||||
.mb-2 {
|
||||
margin-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: calc(var(--spacing) * 8);
|
||||
}
|
||||
@@ -6956,6 +6965,9 @@
|
||||
.h-36 {
|
||||
height: calc(var(--spacing) * 36);
|
||||
}
|
||||
.h-\[1em\] {
|
||||
height: 1em;
|
||||
}
|
||||
.h-\[var\(--radix-select-trigger-height\)\] {
|
||||
height: var(--radix-select-trigger-height);
|
||||
}
|
||||
@@ -7135,6 +7147,12 @@
|
||||
.w-\[100px\] {
|
||||
width: 100px;
|
||||
}
|
||||
.w-\[400px\] {
|
||||
width: 400px;
|
||||
}
|
||||
.w-\[600px\] {
|
||||
width: 600px;
|
||||
}
|
||||
.w-auto {
|
||||
width: auto;
|
||||
}
|
||||
@@ -7147,6 +7165,12 @@
|
||||
.w-screen {
|
||||
width: 100vw;
|
||||
}
|
||||
.w-xl {
|
||||
width: var(--container-xl);
|
||||
}
|
||||
.w-xs {
|
||||
width: var(--container-xs);
|
||||
}
|
||||
.max-w-2xl {
|
||||
max-width: var(--container-2xl);
|
||||
}
|
||||
@@ -7872,6 +7896,9 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.overflow-x-hidden {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -8223,6 +8250,10 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
}
|
||||
.border-1 {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
}
|
||||
.border-2 {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
@@ -8399,6 +8430,18 @@
|
||||
.border-\[\#fbf0df\] {
|
||||
border-color: #fbf0df;
|
||||
}
|
||||
.border-base-300 {
|
||||
border-color: var(--color-base-300);
|
||||
}
|
||||
.border-base-content\/5 {
|
||||
border-color: var(--color-base-content);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
}
|
||||
}
|
||||
.border-gray-100 {
|
||||
border-color: var(--color-gray-100);
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: var(--color-gray-200);
|
||||
}
|
||||
@@ -8565,6 +8608,12 @@
|
||||
.bg-\[\#fbf0df\] {
|
||||
background-color: #fbf0df;
|
||||
}
|
||||
.bg-base-100 {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
.bg-base-200 {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
.bg-blue-400 {
|
||||
background-color: var(--color-blue-400);
|
||||
}
|
||||
@@ -10168,6 +10217,9 @@
|
||||
.text-blue-400 {
|
||||
color: var(--color-blue-400);
|
||||
}
|
||||
.text-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.text-gray-400 {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
@@ -10177,6 +10229,9 @@
|
||||
.text-green-400 {
|
||||
color: var(--color-green-400);
|
||||
}
|
||||
.text-info {
|
||||
color: var(--color-info);
|
||||
}
|
||||
.text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@@ -10186,6 +10241,9 @@
|
||||
.text-slate-900 {
|
||||
color: var(--color-slate-900);
|
||||
}
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ extension DatabaseClient.Migrations: DependencyKey {
|
||||
public static let liveValue = Self(
|
||||
run: {
|
||||
[
|
||||
User.Migrate(),
|
||||
User.Token.Migrate(),
|
||||
Project.Migrate(),
|
||||
ComponentPressureLoss.Migrate(),
|
||||
EquipmentInfo.Migrate(),
|
||||
|
||||
294
Sources/DatabaseClient/Users.swift
Normal file
294
Sources/DatabaseClient/Users.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Fluent
|
||||
import ManualDCore
|
||||
import Vapor
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct Users: Sendable {
|
||||
public var create: @Sendable (User.Create) async throws -> User
|
||||
public var delete: @Sendable (User.ID) async throws -> Void
|
||||
public var get: @Sendable (User.ID) async throws -> User?
|
||||
public var login: @Sendable (User.Login) async throws -> User.Token
|
||||
public var logout: @Sendable (User.Token.ID) async throws -> Void
|
||||
// public var token: @Sendable (User.ID) async throws -> User.Token
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DatabaseClient.Users: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
guard let model = try await UserModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try await model.delete(on: database)
|
||||
},
|
||||
get: { id in
|
||||
try await UserModel.find(id, on: database).map { try $0.toDTO() }
|
||||
},
|
||||
login: { request in
|
||||
guard
|
||||
let user = try await UserModel.query(on: database)
|
||||
.with(\.$token)
|
||||
.filter(\UserModel.$email == request.email)
|
||||
.first()
|
||||
else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
|
||||
let token: User.Token
|
||||
|
||||
// Check if there's a user token
|
||||
if let userToken = user.token {
|
||||
token = try userToken.toDTO()
|
||||
} else {
|
||||
// generate a new token
|
||||
let tokenModel = try user.generateToken()
|
||||
try await tokenModel.save(on: database)
|
||||
token = try tokenModel.toDTO()
|
||||
}
|
||||
|
||||
return token
|
||||
|
||||
},
|
||||
logout: { tokenID in
|
||||
guard let token = try await UserTokenModel.find(tokenID, on: database) else { return }
|
||||
try await token.delete(on: database)
|
||||
}
|
||||
// ,
|
||||
// token: { id in
|
||||
// }
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension User {
|
||||
struct Migrate: AsyncMigration {
|
||||
let name = "CreateUser"
|
||||
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(UserModel.schema)
|
||||
.id()
|
||||
.field("username", .string, .required)
|
||||
.field("email", .string, .required)
|
||||
.field("password_hash", .string, .required)
|
||||
.field("created_at", .datetime)
|
||||
.field("updated_at", .datetime)
|
||||
.unique(on: "email", "username")
|
||||
.create()
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(UserModel.schema).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension User.Token {
|
||||
struct Migrate: AsyncMigration {
|
||||
let name = "CreateUserToken"
|
||||
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(UserTokenModel.schema)
|
||||
.id()
|
||||
.field("value", .string, .required)
|
||||
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
|
||||
.unique(on: "value")
|
||||
.create()
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(UserTokenModel.schema).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension User {
|
||||
|
||||
static func hashPassword(_ password: String) throws -> String {
|
||||
try Bcrypt.hash(password, cost: 12)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension User.Create {
|
||||
|
||||
func toModel() throws -> UserModel {
|
||||
try validate()
|
||||
return try .init(username: username, email: email, passwordHash: User.hashPassword(password))
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
guard !username.isEmpty else {
|
||||
throw ValidationError("Username should not be empty.")
|
||||
}
|
||||
guard !email.isEmpty else {
|
||||
throw ValidationError("Email should not be empty")
|
||||
}
|
||||
guard password.count > 8 else {
|
||||
throw ValidationError("Password should be more than 8 characters long.")
|
||||
}
|
||||
guard password == confirmPassword else {
|
||||
throw ValidationError("Passwords do not match.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class UserModel: Model, @unchecked Sendable {
|
||||
|
||||
static let schema = "user"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "username")
|
||||
var username: String
|
||||
|
||||
@Field(key: "email")
|
||||
var email: String
|
||||
|
||||
@Field(key: "password_hash")
|
||||
var passwordHash: String
|
||||
|
||||
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||
var createdAt: Date?
|
||||
|
||||
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
|
||||
var updatedAt: Date?
|
||||
|
||||
@OptionalChild(for: \.$user)
|
||||
var token: UserTokenModel?
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
id: UUID? = nil,
|
||||
username: String,
|
||||
email: String,
|
||||
passwordHash: String
|
||||
) {
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.passwordHash = passwordHash
|
||||
}
|
||||
|
||||
func toDTO() throws -> User {
|
||||
try .init(
|
||||
id: requireID(),
|
||||
email: email,
|
||||
username: username,
|
||||
createdAt: createdAt!,
|
||||
updatedAt: updatedAt!
|
||||
)
|
||||
}
|
||||
|
||||
func generateToken() throws -> UserTokenModel {
|
||||
try .init(
|
||||
value: [UInt8].random(count: 16).base64,
|
||||
userID: requireID()
|
||||
)
|
||||
}
|
||||
|
||||
func verifyPassword(_ password: String) throws -> Bool {
|
||||
try Bcrypt.verify(password, created: passwordHash)
|
||||
}
|
||||
}
|
||||
|
||||
final class UserTokenModel: Model, Codable, @unchecked Sendable {
|
||||
|
||||
static let schema = "user_token"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "value")
|
||||
var value: String
|
||||
|
||||
@Parent(key: "user_id")
|
||||
var user: UserModel
|
||||
|
||||
init() {}
|
||||
|
||||
init(id: UUID? = nil, value: String, userID: UserModel.IDValue) {
|
||||
self.id = id
|
||||
self.value = value
|
||||
$user.id = userID
|
||||
}
|
||||
|
||||
func toDTO() throws -> User.Token {
|
||||
try .init(id: requireID(), userID: $user.id, value: value)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
extension User: Authenticatable {}
|
||||
extension User: SessionAuthenticatable {
|
||||
public var sessionID: String { username }
|
||||
}
|
||||
|
||||
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
||||
public typealias User = ManualDCore.User
|
||||
|
||||
public init() {}
|
||||
|
||||
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
|
||||
guard
|
||||
let user = try await UserModel.query(on: request.db)
|
||||
.filter(\UserModel.$username == basic.username)
|
||||
.first(),
|
||||
try user.verifyPassword(basic.password)
|
||||
else {
|
||||
throw Abort(.unauthorized)
|
||||
}
|
||||
try request.auth.login(user.toDTO())
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserTokenAuthenticator: AsyncBearerAuthenticator {
|
||||
public typealias User = ManualDCore.User
|
||||
|
||||
public init() {}
|
||||
|
||||
public func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
|
||||
guard
|
||||
let token = try await UserTokenModel.query(on: request.db)
|
||||
.filter(\UserTokenModel.$value == bearer.token)
|
||||
.with(\UserTokenModel.$user)
|
||||
.first()
|
||||
else {
|
||||
throw Abort(.unauthorized)
|
||||
}
|
||||
try request.auth.login(token.user.toDTO())
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
|
||||
public typealias User = ManualDCore.User
|
||||
|
||||
public init() {}
|
||||
|
||||
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
|
||||
guard
|
||||
let user = try await UserModel.query(on: request.db)
|
||||
.filter(\UserModel.$username == sessionID)
|
||||
.first()
|
||||
else {
|
||||
throw Abort(.unauthorized)
|
||||
}
|
||||
try request.auth.login(user.toDTO())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ extension SiteRoute {
|
||||
case room(RoomRoute)
|
||||
case frictionRate(FrictionRateRoute)
|
||||
case effectiveLength(EffectiveLengthRoute)
|
||||
case user(UserRoute)
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.project)) {
|
||||
@@ -25,6 +26,9 @@ extension SiteRoute {
|
||||
Route(.case(Self.effectiveLength)) {
|
||||
SiteRoute.View.EffectiveLengthRoute.router
|
||||
}
|
||||
Route(.case(Self.user)) {
|
||||
SiteRoute.View.UserRoute.router
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,3 +177,74 @@ extension SiteRoute.View.EffectiveLengthRoute {
|
||||
case group
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.View {
|
||||
public enum UserRoute: Equatable, Sendable {
|
||||
case login(Login)
|
||||
case signup(Signup)
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.login)) {
|
||||
SiteRoute.View.UserRoute.Login.router
|
||||
}
|
||||
Route(.case(Self.signup)) {
|
||||
SiteRoute.View.UserRoute.Signup.router
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.View.UserRoute {
|
||||
|
||||
public enum Login: Equatable, Sendable {
|
||||
case index
|
||||
case submit(User.Login)
|
||||
|
||||
static let rootPath = "login"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.index)) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.submit)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body {
|
||||
FormData {
|
||||
Field("email", .string)
|
||||
Field("password", .string)
|
||||
}
|
||||
.map(.memberwise(User.Login.init))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum Signup: Equatable, Sendable {
|
||||
case index
|
||||
case submit(User.Create)
|
||||
|
||||
static let rootPath = "signup"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.index)) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.submit)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body {
|
||||
FormData {
|
||||
Field("username", .string)
|
||||
Field("email", .string)
|
||||
Field("password", .string)
|
||||
Field("confirmPassword", .string)
|
||||
}
|
||||
.map(.memberwise(User.Create.init))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
70
Sources/ManualDCore/User.swift
Normal file
70
Sources/ManualDCore/User.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
public struct User: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
public let id: UUID
|
||||
public let email: String
|
||||
public let username: String
|
||||
public let createdAt: Date
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
email: String,
|
||||
username: String,
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
extension User {
|
||||
public struct Create: Codable, Equatable, Sendable {
|
||||
|
||||
public let username: String
|
||||
public let email: String
|
||||
public let password: String
|
||||
public let confirmPassword: String
|
||||
|
||||
public init(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
confirmPassword: String
|
||||
) {
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.confirmPassword = confirmPassword
|
||||
}
|
||||
}
|
||||
|
||||
public struct Login: Codable, Equatable, Sendable {
|
||||
public let email: String
|
||||
public let password: String
|
||||
|
||||
public init(email: String, password: String) {
|
||||
self.email = email
|
||||
self.password = password
|
||||
}
|
||||
}
|
||||
|
||||
public struct Token: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
public let id: UUID
|
||||
public let userID: User.ID
|
||||
public let value: String
|
||||
|
||||
public init(id: UUID, userID: User.ID, value: String) {
|
||||
self.id = id
|
||||
self.userID = userID
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public struct Input: HTML, Sendable {
|
||||
.id(id ?? ""), .name(_name), .placeholder(placeholder),
|
||||
.class(
|
||||
"""
|
||||
w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
|
||||
input w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
|
||||
-outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2
|
||||
focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500
|
||||
"""
|
||||
@@ -67,4 +67,30 @@ extension HTMLAttribute where Tag == HTMLTag.input {
|
||||
public static func step(_ value: String) -> Self {
|
||||
.init(name: "step", value: value)
|
||||
}
|
||||
|
||||
public static func minlength(_ value: String) -> Self {
|
||||
.init(name: "minlength", value: value)
|
||||
}
|
||||
|
||||
public static func pattern(value: String) -> Self {
|
||||
.init(name: "pattern", value: value)
|
||||
}
|
||||
|
||||
public static func pattern(_ type: PatternType) -> Self {
|
||||
pattern(value: type.value)
|
||||
}
|
||||
}
|
||||
|
||||
public enum PatternType: Sendable {
|
||||
case password
|
||||
case username
|
||||
|
||||
var value: String {
|
||||
switch self {
|
||||
case .password:
|
||||
return "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
|
||||
case .username:
|
||||
return "[A-Za-z][A-Za-z0-9\\-]*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ extension SVG {
|
||||
public enum Key: Sendable {
|
||||
case circlePlus
|
||||
case close
|
||||
case email
|
||||
case key
|
||||
case squarePen
|
||||
case user
|
||||
|
||||
var svg: String {
|
||||
switch self {
|
||||
@@ -29,10 +32,57 @@ extension SVG {
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
"""
|
||||
case .email:
|
||||
return """
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
case .key:
|
||||
return """
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
|
||||
></path>
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
case .squarePen:
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
||||
"""
|
||||
case .user:
|
||||
return """
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ extension ViewController.Request {
|
||||
return try await route.renderView(isHtmxRequest: isHtmxRequest)
|
||||
case .effectiveLength(let route):
|
||||
return try await route.renderView(isHtmxRequest: isHtmxRequest)
|
||||
case .user(let route):
|
||||
return try await route.renderView(isHtmxRequest: isHtmxRequest)
|
||||
default:
|
||||
// FIX: FIX
|
||||
return mainPage
|
||||
@@ -101,6 +103,24 @@ extension SiteRoute.View.EffectiveLengthRoute {
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.View.UserRoute {
|
||||
|
||||
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
|
||||
switch self {
|
||||
case .login(.index):
|
||||
return MainPage(active: .projects, showSidebar: false) {
|
||||
LoginForm()
|
||||
}
|
||||
case .signup(.index):
|
||||
return MainPage(active: .projects, showSidebar: false) {
|
||||
LoginForm(style: .signup)
|
||||
}
|
||||
default:
|
||||
return div { "Fix Me!" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let mainPage: AnySendableHTML = {
|
||||
MainPage(active: .projects) {
|
||||
div {
|
||||
|
||||
@@ -5,9 +5,15 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
|
||||
public var lang: String { "en" }
|
||||
let inner: Inner
|
||||
let activeTab: Sidebar.ActiveTab
|
||||
let showSidebar: Bool
|
||||
|
||||
init(active activeTab: Sidebar.ActiveTab, _ inner: () -> Inner) {
|
||||
init(
|
||||
active activeTab: Sidebar.ActiveTab,
|
||||
showSidebar: Bool = true,
|
||||
_ inner: () -> Inner
|
||||
) {
|
||||
self.activeTab = activeTab
|
||||
self.showSidebar = showSidebar
|
||||
self.inner = inner()
|
||||
}
|
||||
|
||||
@@ -24,7 +30,9 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
|
||||
// div(.class("bg-white dark:bg-gray-800 dark:text-white")) {
|
||||
div {
|
||||
div(.class("flex flex-row")) {
|
||||
Sidebar(active: activeTab)
|
||||
if showSidebar {
|
||||
Sidebar(active: activeTab)
|
||||
}
|
||||
main(.class("flex flex-col h-screen w-full px-6 py-10")) {
|
||||
inner
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ struct RoomsView: HTML, Sendable {
|
||||
let rooms: [Room]
|
||||
|
||||
var body: some HTML {
|
||||
div(.class("m-10")) {
|
||||
div {
|
||||
Row {
|
||||
h1(.class("text-3xl font-bold pb-6")) { "Room Loads" }
|
||||
h1(.class("text-2xl font-bold")) { "Room Loads" }
|
||||
div(
|
||||
.class("tooltip tooltip-left"),
|
||||
.data("tip", value: "Add room")
|
||||
@@ -20,94 +20,67 @@ struct RoomsView: HTML, Sendable {
|
||||
.hx.get(route: .room(.form(dismiss: false))),
|
||||
.hx.target("#roomForm"),
|
||||
.hx.swap(.outerHTML),
|
||||
.class("btn btn-primary w-[40px]")
|
||||
.class("btn btn-primary w-[40px] text-2xl")
|
||||
) {
|
||||
"+"
|
||||
}
|
||||
}
|
||||
}
|
||||
.attributes(.class("pb-6"))
|
||||
|
||||
div(
|
||||
.id("roomTable"),
|
||||
.class(
|
||||
"""
|
||||
border border-gray-200 rounded-lg shadow-lg
|
||||
grid grid-cols-5 p-4
|
||||
"""
|
||||
)
|
||||
) {
|
||||
// Header
|
||||
Label("Name")
|
||||
// Pushes items to right
|
||||
Row {
|
||||
div {}
|
||||
Label("Heating Load")
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Label("Cooling Total")
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Label("Cooling Sensible")
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Label("Register Count")
|
||||
}
|
||||
|
||||
// Divider
|
||||
div(.class("border-b border-gray-200 col-span-5 mb-2")) {}
|
||||
|
||||
// Rows
|
||||
for row in rooms {
|
||||
span { row.name }
|
||||
// Pushes items to right
|
||||
Row {
|
||||
div {}
|
||||
Number(row.heatingLoad)
|
||||
.attributes(.class("text-red-500"))
|
||||
div(.class("overflow-x-auto rounded-box border")) {
|
||||
table(.class("table table-zebra"), .id("roomsTable")) {
|
||||
thead {
|
||||
tr {
|
||||
th { Label("Name") }
|
||||
th { Label("Heating Load") }
|
||||
th { Label("Cooling Total") }
|
||||
th { Label("Cooling Sensible") }
|
||||
th { Label("Register Count") }
|
||||
}
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Number(row.coolingLoad.total)
|
||||
.attributes(.class("text-green-400"))
|
||||
tbody {
|
||||
for room in rooms {
|
||||
tr {
|
||||
td { room.name }
|
||||
td {
|
||||
Number(room.heatingLoad)
|
||||
.attributes(.class("text-error"))
|
||||
}
|
||||
td {
|
||||
Number(room.coolingLoad.total)
|
||||
.attributes(.class("text-success"))
|
||||
}
|
||||
td {
|
||||
Number(room.coolingLoad.sensible)
|
||||
.attributes(.class("text-info"))
|
||||
}
|
||||
td {
|
||||
Number(room.registerCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TOTALS
|
||||
tr(.class("font-bold text-xl")) {
|
||||
td { Label("Total") }
|
||||
td {
|
||||
Number(rooms.heatingTotal)
|
||||
.attributes(.class("badge badge-outline badge-error badge-xl"))
|
||||
}
|
||||
td {
|
||||
Number(rooms.coolingTotal)
|
||||
.attributes(
|
||||
.class("badge badge-outline badge-success badge-xl"))
|
||||
}
|
||||
td {
|
||||
Number(rooms.coolingSensibleTotal)
|
||||
.attributes(.class("badge badge-outline badge-info badge-xl"))
|
||||
}
|
||||
td {}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Number(row.coolingLoad.sensible)
|
||||
.attributes(.class("text-blue-400"))
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Number(row.registerCount)
|
||||
}
|
||||
|
||||
// Divider
|
||||
div(.class("border-b border-gray-200 col-span-5 mb-2")) {}
|
||||
}
|
||||
|
||||
// Totals
|
||||
Label("Total")
|
||||
Row {
|
||||
div {}
|
||||
Number(rooms.heatingTotal)
|
||||
.attributes(.class("badge badge-outline badge-error badge-xl text-xl font-bold"))
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Number(rooms.coolingTotal)
|
||||
.attributes(.class("badge badge-outline badge-success badge-xl text-xl font-bold"))
|
||||
}
|
||||
Row {
|
||||
div {}
|
||||
Number(rooms.coolingSensibleTotal)
|
||||
.attributes(.class("badge badge-outline badge-info badge-xl text-xl font-bold"))
|
||||
}
|
||||
// Empty register count column
|
||||
div {}
|
||||
}
|
||||
|
||||
RoomForm(dismiss: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Elementary
|
||||
import ManualDCore
|
||||
import Styleguide
|
||||
|
||||
// TODO: Need to add active to sidebar links.
|
||||
// TODO: Update to use DaisyUI drawer.
|
||||
struct Sidebar: HTML {
|
||||
|
||||
let active: ActiveTab
|
||||
@@ -23,7 +23,7 @@ struct Sidebar: HTML {
|
||||
Label("Theme")
|
||||
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
|
||||
}
|
||||
.attributes(.class("py-4"))
|
||||
.attributes(.class("p-4"))
|
||||
|
||||
row(title: "Project", icon: .mapPin, route: .project(.index))
|
||||
.attributes(.data("active", value: active == .projects ? "true" : "false"))
|
||||
|
||||
158
Sources/ViewController/Views/User/LoginForm.swift
Normal file
158
Sources/ViewController/Views/User/LoginForm.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import Styleguide
|
||||
|
||||
struct LoginForm: HTML, Sendable {
|
||||
|
||||
let style: Style
|
||||
|
||||
init(style: Style = .login) {
|
||||
self.style = style
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
form {
|
||||
fieldset(.class("fieldset bg-base-200 border-base-300 rounded-box w-xl border p-4")) {
|
||||
legend(.class("fieldset-legend")) { style.title }
|
||||
|
||||
if style == .signup {
|
||||
label(.class("input validator w-full")) {
|
||||
SVG(.user)
|
||||
input(
|
||||
.type(.text), .required, .placeholder("Username"),
|
||||
.minlength("3"), .pattern(.username)
|
||||
)
|
||||
}
|
||||
div(.class("validator-hint hidden")) {
|
||||
"Enter valid username"
|
||||
br()
|
||||
"Must be at least 3 characters"
|
||||
}
|
||||
}
|
||||
|
||||
label(.class("input validator w-full")) {
|
||||
SVG(.email)
|
||||
input(
|
||||
.type(.email), .placeholder("Email"), .required
|
||||
)
|
||||
}
|
||||
div(.class("validator-hint hidden")) { "Enter valid email address." }
|
||||
|
||||
label(.class("input validator w-full")) {
|
||||
SVG(.key)
|
||||
input(
|
||||
.type(.password), .placeholder("Password"), .required,
|
||||
.pattern(.password), .minlength("8")
|
||||
)
|
||||
}
|
||||
|
||||
if style == .signup {
|
||||
label(.class("input validator w-full")) {
|
||||
SVG(.key)
|
||||
input(
|
||||
.type(.password), .placeholder("Confirm Password"), .required,
|
||||
.pattern(.password), .minlength("8")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
div(.class("validator-hint hidden")) {
|
||||
p {
|
||||
"Must be more than 8 characters, including"
|
||||
br()
|
||||
"At least one number"
|
||||
br()
|
||||
"At least one lowercase letter"
|
||||
br()
|
||||
"At least one uppercase letter"
|
||||
}
|
||||
}
|
||||
|
||||
button(.class("btn btn-neutral mt-4")) { style.title }
|
||||
}
|
||||
}
|
||||
// div(.class("flex items-center justify-center")) {
|
||||
// div(.class("w-full mx-auto")) {
|
||||
// h1(.class("text-2xl font-bold")) { style.title }
|
||||
// form(
|
||||
// .class("w-full h-screen")
|
||||
// ) {
|
||||
// fieldset(.class("fieldset w-full")) {
|
||||
// legend(.class("fieldset-legend")) { "Email" }
|
||||
// label(.class("input validator")) {
|
||||
// SVG(.email)
|
||||
// input(
|
||||
// .type(.email), .placeholder("mail@site.com"), .required,
|
||||
// .autofocus
|
||||
// )
|
||||
// }
|
||||
// div(.class("validator-hint hidden")) { "Enter valid email address." }
|
||||
// }
|
||||
//
|
||||
// if style == .signup {
|
||||
// fieldset(.class("fieldset")) {
|
||||
// legend(.class("fieldset-legend")) { "Name" }
|
||||
// label(.class("input validator")) {
|
||||
// input(
|
||||
// .type(.text), .placeholder("Username"), .required,
|
||||
// .init(name: "pattern", value: "[A-Za-z][A-Za-z0-9\\-]*"),
|
||||
// .init(name: "minlength", value: "3")
|
||||
// )
|
||||
// }
|
||||
// div(.class("validator-hint hidden")) { "Enter valid email address." }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fieldset(.class("fieldset")) {
|
||||
// legend(.class("fieldset-legend")) { "Password" }
|
||||
// label(.class("input validator")) {
|
||||
// SVG(.key)
|
||||
// input(
|
||||
// .type(.password), .placeholder("Password"), .required,
|
||||
// .init(name: "pattern", value: "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"),
|
||||
// .init(name: "minlength", value: "8")
|
||||
// )
|
||||
// }
|
||||
// if style == .signup {
|
||||
// label(.class("input validator")) {
|
||||
// SVG(.key)
|
||||
// input(
|
||||
// .type(.password), .placeholder("Confirm Password"), .required,
|
||||
// .init(name: "pattern", value: "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"),
|
||||
// .init(name: "minlength", value: "8")
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// div(.class("validator-hint hidden")) {
|
||||
// p {
|
||||
// "Must be more than 8 characters, including"
|
||||
// br()
|
||||
// "At least one number"
|
||||
// br()
|
||||
// "At least one lowercase letter"
|
||||
// br()
|
||||
// "At least one uppercase letter"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// SubmitButton(title: style.title)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginForm {
|
||||
enum Style: Equatable, Sendable {
|
||||
case login
|
||||
case signup
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .login: return "Login"
|
||||
case .signup: return "Sign Up"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
Sources/ViewController/Views/User/SignupForm.swift
Normal file
0
Sources/ViewController/Views/User/SignupForm.swift
Normal file
Reference in New Issue
Block a user