commit
88364fc096
|
|
@ -0,0 +1,2 @@
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
Packages
|
||||||
|
.build
|
||||||
|
xcuserdata
|
||||||
|
*.xcodeproj
|
||||||
|
DerivedData/
|
||||||
|
.DS_Store
|
||||||
|
db.sqlite
|
||||||
|
.swiftpm
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["swiftlang.swift-vscode", "Vapor.vapor-vscode"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
# ================================
|
||||||
|
# Build image
|
||||||
|
# ================================
|
||||||
|
FROM swift:6.0-noble AS build
|
||||||
|
|
||||||
|
# Install OS updates
|
||||||
|
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||||
|
&& apt-get -q update \
|
||||||
|
&& apt-get -q dist-upgrade -y \
|
||||||
|
&& apt-get install -y libjemalloc-dev
|
||||||
|
|
||||||
|
# Set up a build area
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# First just resolve dependencies.
|
||||||
|
# This creates a cached layer that can be reused
|
||||||
|
# as long as your Package.swift/Package.resolved
|
||||||
|
# files do not change.
|
||||||
|
COPY ./Package.* ./
|
||||||
|
RUN swift package resolve \
|
||||||
|
$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)
|
||||||
|
|
||||||
|
# Copy entire repo into container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application, with optimizations, with static linking, and using jemalloc
|
||||||
|
# N.B.: The static version of jemalloc is incompatible with the static Swift runtime.
|
||||||
|
RUN swift build -c release \
|
||||||
|
--product PostgresWithUsers \
|
||||||
|
--static-swift-stdlib \
|
||||||
|
-Xlinker -ljemalloc
|
||||||
|
|
||||||
|
# Switch to the staging area
|
||||||
|
WORKDIR /staging
|
||||||
|
|
||||||
|
# Copy main executable to staging area
|
||||||
|
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/PostgresWithUsers" ./
|
||||||
|
|
||||||
|
# Copy static swift backtracer binary to staging area
|
||||||
|
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
||||||
|
|
||||||
|
# Copy resources bundled by SPM to staging area
|
||||||
|
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
|
||||||
|
|
||||||
|
# Copy any resources from the public directory and views directory if the directories exist
|
||||||
|
# Ensure that by default, neither the directory nor any of its contents are writable.
|
||||||
|
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
|
||||||
|
RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Run image
|
||||||
|
# ================================
|
||||||
|
FROM ubuntu:noble
|
||||||
|
|
||||||
|
# Make sure all system packages are up to date, and install only essential packages.
|
||||||
|
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||||
|
&& apt-get -q update \
|
||||||
|
&& apt-get -q dist-upgrade -y \
|
||||||
|
&& apt-get -q install -y \
|
||||||
|
libjemalloc2 \
|
||||||
|
ca-certificates \
|
||||||
|
tzdata \
|
||||||
|
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
|
||||||
|
# libcurl4 \
|
||||||
|
# If your app or its dependencies import FoundationXML, also install `libxml2`.
|
||||||
|
# libxml2 \
|
||||||
|
&& rm -r /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a vapor user and group with /app as its home directory
|
||||||
|
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor
|
||||||
|
|
||||||
|
# Switch to the new home directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built executable and any staged resources from builder
|
||||||
|
COPY --from=build --chown=vapor:vapor /staging /app
|
||||||
|
|
||||||
|
# Provide configuration needed by the built-in crash reporter and some sensible default behaviors.
|
||||||
|
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
|
||||||
|
|
||||||
|
# Ensure all further commands run as the vapor user
|
||||||
|
USER vapor:vapor
|
||||||
|
|
||||||
|
# Let Docker bind to port 8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Start the Vapor service when the image is run, default to listening on 8080 in production environment
|
||||||
|
ENTRYPOINT ["./PostgresWithUsers"]
|
||||||
|
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
{
|
||||||
|
"originHash" : "39da4f005434f990c0dece54faba957e7edc27104875be011d48221ad538739d",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "async-http-client",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swift-server/async-http-client.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "60235983163d040f343a489f7e2e77c1918a8bd9",
|
||||||
|
"version" : "1.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "async-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/async-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31",
|
||||||
|
"version" : "1.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "console-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/console-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b",
|
||||||
|
"version" : "4.15.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "leaf",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/leaf.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b70a6108e4917f338f6b8848407bf655aa7e405f",
|
||||||
|
"version" : "4.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "leaf-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/leaf-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "cf186d8f2ef33e16fd1dd78df36466c22c2e632f",
|
||||||
|
"version" : "1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "multipart-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/multipart-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3498e60218e6003894ff95192d756e238c01f44e",
|
||||||
|
"version" : "4.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "postgres-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/postgres-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f4d4b9e8db9a907644d67d6a7ecb5f0314eec1ad",
|
||||||
|
"version" : "2.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "postgres-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/postgres-nio.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ccb25dcc428587224633a79c0ce0430eeac3dc0f",
|
||||||
|
"version" : "1.26.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "routing-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/routing-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea",
|
||||||
|
"version" : "4.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sql-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/sql-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0169d063f6eb9f384a85c5ccc881fe2b32de53f1",
|
||||||
|
"version" : "3.33.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-algorithms",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-algorithms.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
|
||||||
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-asn1",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-asn1.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f70225981241859eb4aa1a18a75531d26637c8cc",
|
||||||
|
"version" : "1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-async-algorithms",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-async-algorithms.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b",
|
||||||
|
"version" : "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-atomics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-certificates",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-certificates.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "870f4d5fe5fcfedc13f25d70e103150511746404",
|
||||||
|
"version" : "1.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
|
||||||
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-crypto",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-crypto.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "176abc28e002a9952470f08745cd26fad9286776",
|
||||||
|
"version" : "3.13.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-distributed-tracing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-distributed-tracing.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b78796709d243d5438b36e74ce3c5ec2d2ece4d8",
|
||||||
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-http-structured-headers",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-http-structured-headers.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-http-types",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-http-types.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
|
||||||
|
"version" : "1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-log",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-log.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
|
||||||
|
"version" : "1.6.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-metrics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-metrics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4c83e1cdf4ba538ef6e43a9bbd0bcc33a0ca46e3",
|
||||||
|
"version" : "2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a5fea865badcb1c993c85b0f0e8d05a4bd2270fb",
|
||||||
|
"version" : "2.85.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio-extras",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio-extras.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d",
|
||||||
|
"version" : "1.29.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio-http2",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5",
|
||||||
|
"version" : "1.38.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio-ssl",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "385f5bd783ffbfff46b246a7db7be8e4f04c53bd",
|
||||||
|
"version" : "2.33.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio-transport-services",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "decfd235996bc163b44e10b8a24997a3d2104b90",
|
||||||
|
"version" : "1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-numerics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-numerics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8",
|
||||||
|
"version" : "1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-service-context",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-service-context.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6",
|
||||||
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-service-lifecycle",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e7187309187695115033536e8fc9b2eb87fd956d",
|
||||||
|
"version" : "2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-smtp",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sersoft-gmbh/swift-smtp.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a1bafbaaaadd12d69b1813d2fa2f2b411b31ec11",
|
||||||
|
"version" : "2.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-system",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-system.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "890830fff1a577dc83134890c7984020c5f6b43b",
|
||||||
|
"version" : "1.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "vapor",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/vapor.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3636f443474769147828a5863e81a31f6f30e92c",
|
||||||
|
"version" : "4.115.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "websocket-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/websocket-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167",
|
||||||
|
"version" : "2.16.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
// swift-tools-version:6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ManagableUsers",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "ManagableUsers",
|
||||||
|
targets: ["ManagableUsers"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// 💧 A server-side Swift web framework.
|
||||||
|
.package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"),
|
||||||
|
// 🗄 An ORM for SQL and NoSQL databases.
|
||||||
|
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
|
||||||
|
// 🐘 Fluent driver for Postgres.
|
||||||
|
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
|
||||||
|
// 🍃 An expressive, performant, and extensible templating language built for Swift.
|
||||||
|
.package(url: "https://github.com/vapor/leaf.git", from: "4.3.0"),
|
||||||
|
// 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors
|
||||||
|
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
|
||||||
|
// ✉️ A SwiftNIO based implementation for sending emails using SMTP servers.
|
||||||
|
.package(url: "https://github.com/sersoft-gmbh/swift-smtp.git", from: "2.0.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "ManagableUsers",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Fluent", package: "fluent"),
|
||||||
|
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
|
||||||
|
.product(name: "Leaf", package: "leaf"),
|
||||||
|
.product(name: "Vapor", package: "vapor"),
|
||||||
|
.product(name: "NIOCore", package: "swift-nio"),
|
||||||
|
.product(name: "NIOPosix", package: "swift-nio"),
|
||||||
|
.product(name: "SwiftSMTPVapor", package: "swift-smtp")
|
||||||
|
],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "SampleApp",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "ManagableUsers"),
|
||||||
|
],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "SampleAppTests",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "SampleApp"),
|
||||||
|
.product(name: "VaporTesting", package: "vapor"),
|
||||||
|
],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
var swiftSettings: [SwiftSetting] { [
|
||||||
|
.enableUpcomingFeature("ExistentialAny"),
|
||||||
|
] }
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="24px" height="30px" viewBox="0 0 30 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="0" y1="0" x2="30" y2="0" stroke="white" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="0" y1="10" x2="30" y2="10" stroke="white" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="0" y1="20" x2="30" y2="20" stroke="white" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
|
|
@ -0,0 +1,395 @@
|
||||||
|
async function loadData (block) {
|
||||||
|
const source = block.dataset.source;
|
||||||
|
block.classList.remove ("loaded");
|
||||||
|
try {
|
||||||
|
const response = await fetch (source);
|
||||||
|
if (response.ok) {
|
||||||
|
const items = await response.json();
|
||||||
|
|
||||||
|
if (Array.isArray (items)) {
|
||||||
|
const template = block.querySelector ("template");
|
||||||
|
const tableBody = block.querySelector ("table tbody");
|
||||||
|
tableBody.innerHTML = "";
|
||||||
|
items.forEach (item => {
|
||||||
|
const clone = template.content.cloneNode (true);
|
||||||
|
window["populate" + block.dataset.item] (clone, item);
|
||||||
|
tableBody.appendChild (clone);
|
||||||
|
tableBody.lastElementChild.dataset.itemid = item.id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window["populate" + block.dataset.item] (block, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
block.classList.add ("loaded");
|
||||||
|
} else {
|
||||||
|
if ((response.status == 401) || (response.status == 403)) {
|
||||||
|
document.location.href = document.body.dataset.baseurl;
|
||||||
|
} else {
|
||||||
|
block.classList.add ("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
block.classList.add ("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateUser (row, user) {
|
||||||
|
row.querySelector (".email").innerText = user.email;
|
||||||
|
row.querySelector (".fullname").innerText = user.fullName;
|
||||||
|
row.querySelector (".roles").innerText = user.roles.join (", ");
|
||||||
|
row.querySelector (".active").innerText = user.isActive ? "✓" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function displayUser (container, dialog, userUrl) {
|
||||||
|
dialog.querySelectorAll (".error")
|
||||||
|
.forEach (element => {
|
||||||
|
element.hidden = true;
|
||||||
|
});
|
||||||
|
const response = await fetch (userUrl);
|
||||||
|
if (response.ok) {
|
||||||
|
const user = await response.json();
|
||||||
|
dialog.querySelector ("input[name=userid]").value = user.id;
|
||||||
|
dialog.querySelector ("input[name=email]").value = user.email;
|
||||||
|
dialog.querySelector ("input[name=fullname]").value = user.fullName;
|
||||||
|
dialog.querySelector ("input[name=active]").checked = user.isActive;
|
||||||
|
dialog.querySelectorAll ("input.role")
|
||||||
|
.forEach (checkbox => {
|
||||||
|
checkbox.checked = user.roles.includes (checkbox.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.classList.add ("loaded");
|
||||||
|
} else {
|
||||||
|
if ((response.status == 401) || (response.status == 403)) {
|
||||||
|
document.location.href = document.body.dataset.baseurl;
|
||||||
|
} else {
|
||||||
|
container.classList.add ("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyUser (dialog) {
|
||||||
|
dialog.querySelector ("input[name=email]").value = null;
|
||||||
|
dialog.querySelector ("input[name=fullname]").value = null;
|
||||||
|
dialog.querySelectorAll ("input[name=role]")
|
||||||
|
.forEach (checkbox => {
|
||||||
|
checkbox.checked = false;
|
||||||
|
});
|
||||||
|
dialog.querySelectorAll (".error")
|
||||||
|
.forEach (element => {
|
||||||
|
element.hidden = true;
|
||||||
|
});
|
||||||
|
dialog.querySelector ("button.invite").disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser (event) {
|
||||||
|
const row = event.target.closest ("tbody tr");
|
||||||
|
|
||||||
|
if (row && this.contains (row)) {
|
||||||
|
const dialog = document.querySelector (this.dataset.dialog);
|
||||||
|
|
||||||
|
window["display" + this.dataset.item] (this, dialog, this.dataset.source + "/" + row.dataset.itemid);
|
||||||
|
dialog.showModal();
|
||||||
|
dialog.querySelector ("input[name=email]").focus();
|
||||||
|
} else if (event.target.classList.contains ("new")) {
|
||||||
|
const dialog = document.querySelector (event.target.dataset.dialog);
|
||||||
|
|
||||||
|
window["empty" + this.dataset.item] (dialog);
|
||||||
|
dialog.showModal();
|
||||||
|
dialog.querySelector ("input[name=email]").focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const dialog = event.target.closest ("dialog");
|
||||||
|
|
||||||
|
if (event.submitter.classList.contains ("save")) {
|
||||||
|
const userId = event.target.querySelector ("input[name=userid]").value;
|
||||||
|
const email = event.target.querySelector ("input[name=email]").value;
|
||||||
|
const fullname = event.target.querySelector ("input[name=fullname]").value;
|
||||||
|
const isActive = event.target.querySelector ("input[name=active]").checked;
|
||||||
|
const roles = Array.from (event.target.querySelectorAll ("input[name=role]:checked")).map (checkbox => { return checkbox.value });
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
dialog.querySelector (".error[for=email]").hidden = true;
|
||||||
|
} else {
|
||||||
|
dialog.querySelector (".error[for=email]").hidden = false;
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (fullname) {
|
||||||
|
dialog.querySelector (".error[for=fullname]").hidden = true;
|
||||||
|
} else {
|
||||||
|
dialog.querySelector (".error[for=fullname]").hidden = false;
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
event.target.querySelector ("button.save").disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = dialog.dataset.saveUrl + "/" + userId;
|
||||||
|
const response = await fetch (url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify ({email: email, fullname: fullname, isActive: isActive, roles: roles})
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
dialog.close();
|
||||||
|
event.target.querySelector ("button.save").disabled = false;
|
||||||
|
loadData (document.querySelector (".users.loading"));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error (url + " returned " + response.status);
|
||||||
|
dialog.querySelector (".error#failure").hidden = false;
|
||||||
|
dialog.querySelector ("button.save").disabled = false;
|
||||||
|
|
||||||
|
if ((response.status == 401) || (response.status == 403)) {
|
||||||
|
document.location.href = document.body.dataset.baseurl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error (error);
|
||||||
|
dialog.querySelector (".error#failure").hidden = false;
|
||||||
|
dialog.querySelector ("button.save").disabled = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteUser (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const dialog = event.target.closest ("dialog");
|
||||||
|
|
||||||
|
if (event.submitter.classList.contains ("invite")) {
|
||||||
|
const email = event.target.querySelector ("input[name=email]").value;
|
||||||
|
const fullname = event.target.querySelector ("input[name=fullname").value;
|
||||||
|
const roles = Array.from (event.target.querySelectorAll ("input[name=role]:checked")).map (checkbox => { return checkbox.value });
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
dialog.querySelector (".error[for=email]").hidden = true;
|
||||||
|
} else {
|
||||||
|
dialog.querySelector (".error[for=email]").hidden = false;
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (fullname) {
|
||||||
|
dialog.querySelector (".error[for=fullname]").hidden = true;
|
||||||
|
} else {
|
||||||
|
dialog.querySelector (".error[for=fullname]").hidden = false;
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
event.target.querySelector ("button.invite").disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = dialog.dataset.inviteUrl;
|
||||||
|
const response = await fetch (url,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify ({email: email, fullname: fullname, roles: roles})
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
dialog.close();
|
||||||
|
event.target.querySelector ("button.invite").disabled = false;
|
||||||
|
loadData (document.querySelector (".users.loading"));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error (url + " returned " + response.status);
|
||||||
|
dialog.querySelector (".error#failure").hidden = false;
|
||||||
|
dialog.querySelector ("button.invite").disabled = false;
|
||||||
|
|
||||||
|
if ((response.status == 401) || (response.status == 403)) {
|
||||||
|
document.location.href = document.body.dataset.baseurl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error (error);
|
||||||
|
dialog.querySelector (".error#failure").hidden = false;
|
||||||
|
dialog.querySelector ("button.invite").disabled = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populatePassword (container, token) {
|
||||||
|
container.querySelector (".email").innerText = token.email;
|
||||||
|
container.querySelector ("button.save").disabled = false;
|
||||||
|
container.querySelector (".invalidPassword").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (event.submitter.classList.contains ("save")) {
|
||||||
|
const password = event.target.querySelector ("input[name=password]").value;
|
||||||
|
const saveUrl = event.target.dataset.saveUrl;
|
||||||
|
const loginUrl = event.target.dataset.loginUrl;
|
||||||
|
|
||||||
|
if (password && (password.length >= 12)) {
|
||||||
|
try {
|
||||||
|
event.target.querySelector ("button.save").disabled = true;
|
||||||
|
event.target.querySelector (".invalidPassword").hidden = true;
|
||||||
|
const response = await fetch (saveUrl,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify ({password: password})
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
document.location.href = loginUrl;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error (saveUrl + " returned " + response.status);
|
||||||
|
event.target.querySelector (".error#failure").hidden = false;
|
||||||
|
event.target.querySelector ("button.save").disabled = false;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error (saveUrl + " returned " + error);
|
||||||
|
event.target.querySelector (".error#failure").hidden = false;
|
||||||
|
event.target.querySelector ("button.save").disabled = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.target.querySelector (".invalidPassword").hidden = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const email = event.target.querySelector ("input[name=email]").value;
|
||||||
|
const password = event.target.querySelector ("input[name=password]").value;
|
||||||
|
const loginUrl = event.target.dataset.loginUrl;
|
||||||
|
|
||||||
|
if (email && password) {
|
||||||
|
try {
|
||||||
|
event.target.querySelector ("button#login").disabled = true;
|
||||||
|
event.target.querySelector (".error#failure").hidden = true;
|
||||||
|
event.target.querySelector (".invalidEmail").hidden = true;
|
||||||
|
event.target.querySelector (".invalidPassword").hidden = true;
|
||||||
|
|
||||||
|
const response = await fetch (loginUrl,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify ({email: email, password: password})
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
event.target.querySelector ("button#login").disabled = false;
|
||||||
|
|
||||||
|
document.location.href = document.body.dataset.baseurl;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error (loginUrl + " returned " + response.status);
|
||||||
|
event.target.querySelector (".error#failure").hidden = false;
|
||||||
|
event.target.querySelector ("button#login").disabled = false;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error (loginUrl + " returned " + error);
|
||||||
|
event.target.querySelector (".error#failure").hidden = false;
|
||||||
|
event.target.querySelector ("button#login").disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
event.target.querySelector (".invalidEmail").hidden = email;
|
||||||
|
event.target.querySelector (".invalidPassword").hidden = password;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showForgotPassword (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
document.querySelector ("form#login").hidden = true;
|
||||||
|
document.querySelector ("form#forgot").hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForgotPassword (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const email = event.target.querySelector ("input[name=email]").value;
|
||||||
|
const resetUrl = event.target.dataset.resetUrl;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
event.target.querySelector ("button#reset-password").disabled = true;
|
||||||
|
const response = await fetch (resetUrl,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify ({email: email})
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
document.querySelector ("form#forgot").hidden = true;
|
||||||
|
document.querySelector ("div#reset-info").hidden = false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error (saveUrl + " returned " + response.status);
|
||||||
|
event.target.querySelector (".error#failure").hidden = false;
|
||||||
|
event.target.querySelector ("button#reset-password").disabled = false;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error (saveUrl + " returned " + error);
|
||||||
|
event.target.querySelector (".error#failure").hidden = false;
|
||||||
|
event.target.querySelector ("button#reset-password").disabled = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.target.querySelector (".invalidEmail").hidden = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGlobalEnvironment() {
|
||||||
|
document.querySelectorAll (".loading")
|
||||||
|
.forEach (function (block) {
|
||||||
|
loadData (block);
|
||||||
|
block.addEventListener ("click", window["select" + block.dataset.item]);
|
||||||
|
});
|
||||||
|
document.querySelectorAll ("form#login")
|
||||||
|
.forEach (function (form) {
|
||||||
|
form.addEventListener ("submit", login);
|
||||||
|
});
|
||||||
|
document.querySelectorAll ("button#forgot-password")
|
||||||
|
.forEach (function (button) {
|
||||||
|
button.addEventListener ("click", showForgotPassword);
|
||||||
|
});
|
||||||
|
document.querySelectorAll ("form#forgot")
|
||||||
|
.forEach (function (form) {
|
||||||
|
form.addEventListener ("submit", submitForgotPassword);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener ("DOMContentLoaded", setupGlobalEnvironment);
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
nav.main {
|
||||||
|
width: 100%;
|
||||||
|
background: black;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
input#menu-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
label[for=menu-toggle] {
|
||||||
|
display: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu-expanded {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5em;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
background-color: darkgray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.main a.active {
|
||||||
|
background-color: lightgray;
|
||||||
|
display: inline-block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading .progress {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.loaded .progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
table, form, .failed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.loaded {
|
||||||
|
table {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.failed {
|
||||||
|
.progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.users table th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users table {
|
||||||
|
td, th {
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.users table tbody tr:hover td {
|
||||||
|
background: #eeeeee;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
form th, form td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(farthest-side,#b7b7b7 94%,#0000) top/3.8px 3.8px no-repeat,
|
||||||
|
conic-gradient(#0000 30%,#b7b7b7);
|
||||||
|
-webkit-mask: radial-gradient(farthest-side,#0000 calc(100% - 3.8px),#000 0);
|
||||||
|
animation: spinner-c7wet2 1.2s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-c7wet2 {
|
||||||
|
100% {
|
||||||
|
transform: rotate(1turn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1em;
|
||||||
|
width: 80%;
|
||||||
|
height: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog > form > textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #7f0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
nav.main {
|
||||||
|
> a {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
label[for=menu-toggle] {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input#menu-toggle:checked ~ div#menu-expanded {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
background: black;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: block !important;
|
||||||
|
padding: 0.5em;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
table, thead, tbody, th, td, tr {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 4em !important;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:before {
|
||||||
|
content: attr(data-label);
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
width: 45%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# ManagableUsers
|
||||||
|
|
||||||
|
A package providing basic user management when using the Vapor web framework and a PostgreSQL database.
|
||||||
|
|
||||||
|
Provides controllers and middleware for authenticating users. This includes web pages for logging in,
|
||||||
|
handling of forgotten password by sending tokens via email, and logging out.
|
||||||
|
|
||||||
|
Provides controllers for managing users. This includes administration pages and inviting users via email.
|
||||||
|
|
||||||
|
*Note: This package uses raw SQL for accessing the database, so there are no `Fluent` model for users,
|
||||||
|
but the database is connected using `Fluent`, so the it can still be used for other items.*
|
||||||
|
|
||||||
|
## Try out
|
||||||
|
|
||||||
|
The package contains an executable target `SampleApp` with a minimal implementation demonstrating the
|
||||||
|
functionality.
|
||||||
|
|
||||||
|
You can try this to asses the functionality provided by this package, or you can use it as a template for starting your own project.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
To run `SampleApp`, a database is needed.
|
||||||
|
|
||||||
|
If you don't already have PostgreSQL installed, refer to [postgresql.org](https://www.postgresql.org/docs/current/tutorial-install.html)
|
||||||
|
for options.
|
||||||
|
|
||||||
|
There's a SQL script provided for creating the tables needed by `SampleApp`.
|
||||||
|
```
|
||||||
|
$ psql --file=Resources/Database/Create.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
For creating a new database named `sampleapp`, create the tables, and a corresponding user do the following:
|
||||||
|
```
|
||||||
|
$ psql --dbname=postgres --command="CREATE DATABASE sampleapp"
|
||||||
|
$ psql --dbname=sampleapp --command="CREATE ROLE sampleapp WITH LOGIN PASSWORD 'sampleapp_password'"
|
||||||
|
$ psql --dbname=sampleapp --file=Resources/Database/Create.sql
|
||||||
|
$ psql --dbname=sampleapp --command="GRANT CONNECT ON DATABASE sampleapp TO sampleapp"
|
||||||
|
$ psql --dbname=sampleapp --command="GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sampleapp"
|
||||||
|
```
|
||||||
|
|
||||||
|
There's also a script for creating an initial user:
|
||||||
|
```
|
||||||
|
$ psql --dbname=sampleapp --file=Resources/Database/InitialUser.sql --variable=email=someone@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will return a password URL for setting the password (assuming that the service runs on https://example.com/, it needs to be changed to the actual host to be usable).
|
||||||
|
The email address is used for logging in, and can also be used for resetting passwords via email if you have email delivery setup.
|
||||||
|
|
||||||
|
### Running Application
|
||||||
|
|
||||||
|
You can run the application with `swift run`, or from an IDE such as Xcode.
|
||||||
|
|
||||||
|
A number of environment variables are needed for proper function.
|
||||||
|
- `DATABASE_HOST`: Host for the PostgreSQL server, defaults to `localhost`
|
||||||
|
- `DATABASE_PORT`: Port for the PostgreSQL server, defaults to `5432`
|
||||||
|
- `DATABASE_NAME`: Name of the database, defaults to `sampleapp`
|
||||||
|
- `DATABASE_USERNAME`: Username for accessing the database, defaults to `sampleapp`
|
||||||
|
- `DATABASE_PASSWORD`: Password for accessing the database, defaults to `sampleapp_password`
|
||||||
|
- `BASE_URL`: URL for accessing the web site (e.g. `http://localhost:8080`)
|
||||||
|
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_ENCRYPTION`, `SMTP_TIMEOUT`, `SMTP_USERNAME`, `SMTP_PASSWORD` and `SMTP_USE_ESMTP`: Configuring email delivery, see [the documentation for the SwiftSMTP package](https://sersoft-gmbh.github.io/swift-smtp/2.15.0/documentation/swiftsmtp#Creating-a-Configuration) for details.
|
||||||
|
- `LOG_LEVEL`: Desired level of logging, defaulting to `info`.
|
||||||
|
|
||||||
|
The application will write to the log that it has started and which port it's listening on.
|
||||||
|
|
||||||
|
### Using application
|
||||||
|
|
||||||
|
Use the URL returned from the `InitialUser.sql` script above in a browser. Make sure to adapt it to the actual setup
|
||||||
|
(e.g. `http://localhost:8080/auth/password/2lG91bumxxvm9Ky7xLy1Nxz2mJoxjRCS` with defaults and without a reverse proxy).
|
||||||
|
|
||||||
|
A form for setting a password should be provided at that URL. After setting a password, that password and the email
|
||||||
|
address provided to the `InitialUser.sql` script can then be used for logging in.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Create a new Vapor project, or reuse an existing one.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Add dependency on `managable-users` by adding the package to `Package.swift` as
|
||||||
|
```swift
|
||||||
|
.package(url: "https://git.carlberg.org/public/manageable-users.git", from: "1.0.0"),
|
||||||
|
```
|
||||||
|
|
||||||
|
…and adding to the executable target's dependencies as
|
||||||
|
```swift
|
||||||
|
.product(name: "ManagableUsers", package: "managable-users"),
|
||||||
|
``
|
||||||
|
|
||||||
|
Copy the contents of the `Resources/Views` folder from the `managable-users` into the `Resources/Views` folder
|
||||||
|
in the new project, and also copy the contents of the `Public` folder into the `Public` folder.
|
||||||
|
|
||||||
|
In the `configure.swift` file, add the import
|
||||||
|
```swift
|
||||||
|
import ManagableUsers
|
||||||
|
```
|
||||||
|
|
||||||
|
…and add
|
||||||
|
```swift
|
||||||
|
try await ManagableUsers.configure (app)
|
||||||
|
```
|
||||||
|
|
||||||
|
into the `configure()` function.
|
||||||
|
|
||||||
|
In the `routes.swift` file, add the import
|
||||||
|
```swift
|
||||||
|
import ManagableUsers
|
||||||
|
```
|
||||||
|
|
||||||
|
…and replace everything in the `routes()` function with
|
||||||
|
```swift
|
||||||
|
let sessioned = app.grouped (app.sessions.middleware)
|
||||||
|
.grouped (BasicUser.sessionAuthenticator())
|
||||||
|
let loggedIn = sessioned
|
||||||
|
.grouped (BasicUser.redirectMiddleware (path: "auth/login"))
|
||||||
|
let api = sessioned
|
||||||
|
.grouped("api")
|
||||||
|
|
||||||
|
AuthenticationController<BasicUser>().routes (sessioned, api: api)
|
||||||
|
AdminController<BasicUser>().routes (loggedIn, api: api)
|
||||||
|
|
||||||
|
struct WelcomeContext: Encodable {
|
||||||
|
let user: BasicUser?
|
||||||
|
let section: String
|
||||||
|
}
|
||||||
|
|
||||||
|
loggedIn.get { req async throws in
|
||||||
|
return try await req.view.render("welcome", WelcomeContext (user: req.auth.get (BasicUser.self), section: "home"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
|
To add useful functionality, just add more routes to `routes.swift`.
|
||||||
|
|
||||||
|
To use a consistent look, use `base.leaf` in your Leaf templates for pages.
|
||||||
|
|
||||||
|
To add items to the main menu, do that in `configure.swift` by replacing
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await ManagableUsers.configure (app)
|
||||||
|
```
|
||||||
|
|
||||||
|
with something like
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await ManagableUsers.configure(app, mainMenu: [MenuItem (section: "inventory", path: "inventory", name: "Inventory")])
|
||||||
|
```
|
||||||
|
|
||||||
|
For `MenuItem`
|
||||||
|
- `section` corresponds to the `section` included in the rendering context supplied to Leaf.
|
||||||
|
- `path` corresponds to the URL path to the start page of that section.
|
||||||
|
- `name` is the name that will be displayed in the menu.
|
||||||
|
- `role` is an optional role that is required to access that section.
|
||||||
|
- If `nil` or omitted, it will be shown to all logged in users.
|
||||||
|
- Corresponds to the `name` column in the `roles` table.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
CREATE EXTENSION citext;
|
||||||
|
CREATE DOMAIN email AS citext
|
||||||
|
CHECK ( value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' );
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"email" email UNIQUE NOT NULL,
|
||||||
|
"full_name" TEXT NOT NULL,
|
||||||
|
"password" CHARACTER VARYING (1000),
|
||||||
|
"active" BOOLEAN NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE "roles" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"name" TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE "user_roles" (
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||||
|
"role_name" TEXT NOT NULL REFERENCES "roles" ("name") ON DELETE NO ACTION
|
||||||
|
);
|
||||||
|
CREATE TABLE "user_tokens" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"token" CHARACTER VARYING (1000) NOT NULL,
|
||||||
|
"insert_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "user_tokens_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "roles" ("name") VALUES ('admin');
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
WITH "created" AS (
|
||||||
|
INSERT INTO "users" (
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"active")
|
||||||
|
VALUES (:'email',
|
||||||
|
'Initial User',
|
||||||
|
TRUE)
|
||||||
|
RETURNING "id"
|
||||||
|
),
|
||||||
|
"roles" AS (
|
||||||
|
INSERT INTO "user_roles" ("user_id", "role_name")
|
||||||
|
SELECT "id", 'admin'
|
||||||
|
FROM "created"
|
||||||
|
)
|
||||||
|
INSERT INTO "user_tokens" ("user_id", "token")
|
||||||
|
SELECT "id", (SELECT string_agg (substr (c, (random() * length (c) + 1)::integer, 1), '') AS "token"
|
||||||
|
FROM (VALUES ('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')) AS x(c),
|
||||||
|
generate_series (1, 32))
|
||||||
|
FROM "created"
|
||||||
|
RETURNING 'https://example.com/auth/password/' || "token" AS "Password URL"
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
#extend("base"):
|
||||||
|
#export("title", "User administration")
|
||||||
|
#export("body"):
|
||||||
|
<div class="users loading list" data-source="#baseURL/api/admin" data-dialog="dialog#user" data-item="User">
|
||||||
|
<div class="progress">Loading <span class="spinner"></span></div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Active</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button class="new" data-dialog="dialog#invite">Invite</button>
|
||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<td class="email" data-label="Email"></td>
|
||||||
|
<td class="fullname" data-label="Name"></td>
|
||||||
|
<td class="roles" data-label="Roles"></td>
|
||||||
|
<td class="active" data-label="Active"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<dialog id="user" data-save-url="#baseURL/api/admin">
|
||||||
|
<form method="post" onsubmit="saveUser (event);">
|
||||||
|
<input type="hidden" name="userid"></input>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><label for="email">Email</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="email" name="email" autocomplete="email"></input>
|
||||||
|
<p class="error" for="email">Email is required</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="fullname">Name</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="fullname" autocomplete="name"></input>
|
||||||
|
<p class="error" for="fullname">Name is required</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="active"></input><label for="active">Active</label></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2">Roles</th>
|
||||||
|
</tr>
|
||||||
|
#for(role in roles): <tr>
|
||||||
|
<td><input type="checkbox" name="role" class="role" value="#(role)"></input><label for="role">#(role)</label></td>
|
||||||
|
</tr>
|
||||||
|
#endfor <tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<button class="save">Save</button><button class="cancel">Cancel</button>
|
||||||
|
<p class="error" id="failure">Failed to save, try again later.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="invite" data-invite-url="#baseURL/api/admin">
|
||||||
|
<form method="post" onsubmit="inviteUser (event);">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><label for="email">Email</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="email" name="email" autocomplete="email"></input>
|
||||||
|
<p class="error" for="email">Email is required</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="fullname">Name</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="fullname" autocomplete="name"></input>
|
||||||
|
<p class="error" for="fullname">Name is required</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
#for(role in roles): <tr>
|
||||||
|
<td><input type="checkbox" name="role" class="role" value="#(role)"></input><label for="role">#(role)</label></td>
|
||||||
|
</tr>
|
||||||
|
#endfor <tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<button class="invite">Invite</button><button class="cancel">Cancel</button>
|
||||||
|
<p class="error" id="failure">Invite failed, try again later.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
#endexport
|
||||||
|
#endextend
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>#import("title")</title>
|
||||||
|
<link rel="stylesheet" href="#baseURL/styles/manage.css" />
|
||||||
|
<script src="#baseURL/scripts/manage.js"></script>
|
||||||
|
<meta name="viewport" content="initial-scale=1.0" />
|
||||||
|
</head>
|
||||||
|
<body data-baseurl="#baseURL">
|
||||||
|
#if(section != "login"): #extend("mainmenu")
|
||||||
|
#endif#import("body")</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
Välkommen till #(host).
|
||||||
|
|
||||||
|
För att aktivera ditt konto, gå till #baseURL/auth/password/#(token) och skriv in ett lösenord.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
Du har begärt ett nytt lösenord på #(host).
|
||||||
|
|
||||||
|
För att ändra ditt lösenord, gå till #baseURL/auth/password/#(token) och skriv in ett lösenord.
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
#extend("base"):
|
||||||
|
#export("title"):Login#endexport
|
||||||
|
#export("body"):
|
||||||
|
<h1>Login</h1>
|
||||||
|
|
||||||
|
<form method="post" id="login" data-login-url="#baseURL/api/auth/login"">
|
||||||
|
<table style="border: none;">
|
||||||
|
<tr>
|
||||||
|
<th>Email:</th>
|
||||||
|
<td><input type="email" name="email" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="error invalidEmail" hidden>
|
||||||
|
<td colspan="2">Enter your email address</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Password:</th>
|
||||||
|
<td><input type="password" name="password" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="error invalidPassword" hidden>
|
||||||
|
<td colspan="2">Enter your password</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><button id="login">Log in</button> <button id="forgot-password">Forgot password</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="error" id="failure" hidden>
|
||||||
|
<td colspan="2">Login failed,<br>check your email and password</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" id="forgot" data-reset-url="#baseURL/api/auth/forgot" hidden>
|
||||||
|
<table style="border: none;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">Enter your email address and click <em>Initiate</em> and we'll send and email with a link you can use to set a new password.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Email:</th>
|
||||||
|
<td><input type="email" name="email" autocomplete="email" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="error invalidPassword" hidden>
|
||||||
|
<td>Enter a password with at least 12 characters</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><button id="reset-password">Initate</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="error" id="failure" hidden>
|
||||||
|
<td>Failed to initate a password reset, try again later</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="reset-info" hidden>
|
||||||
|
<p>We've sent an email to <span id="reset-email"></span> with a link you can use to set a new password.</p>
|
||||||
|
</div>
|
||||||
|
#endexport
|
||||||
|
#endextend
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<nav class="main">
|
||||||
|
<input id="menu-toggle" type="checkbox">
|
||||||
|
<label for="menu-toggle">
|
||||||
|
<img src="#baseURL/images/hamburger.svg">
|
||||||
|
</label>
|
||||||
|
#mainmenuItems(section)
|
||||||
|
<div id="menu-expanded">
|
||||||
|
#mainmenuItems(section)
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
#extend("base"):
|
||||||
|
#export("title"):Reset password#endexport
|
||||||
|
#export("body"):
|
||||||
|
<h1>Reset password</h1>
|
||||||
|
|
||||||
|
<div class="password loading" data-source="#baseURL/api/auth/token/#(token)" data-item="Password">
|
||||||
|
<div class="progress">Loading <span class="spinner"></span></div>
|
||||||
|
<form onsubmit="savePassword (event);" data-save-url="#(baseUrl)/api/auth/password/#(token)" data-login-url="#(baseUrl)/auth/login">
|
||||||
|
<p>Enter a new password for <span class="email"></span></p>
|
||||||
|
<p><input type="password" name="password" autocomplete="new-password"></p>
|
||||||
|
<p class="error invalidPassword" hidden>Enter a password with at least 12 characters</p>
|
||||||
|
<p><button class="save">Save</button></p>
|
||||||
|
<p class="error" id="failure" hidden>Failed to set password, try again later</p>
|
||||||
|
</form>
|
||||||
|
<div class="failed">
|
||||||
|
<p>Invalid link, it has either expired or it has already been used</p>
|
||||||
|
<p>Request a new from the <a href="#baseURL/auth/login">login page</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
#endexport
|
||||||
|
#endextend
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
#extend("base"):
|
||||||
|
#export("title", "Welcome")
|
||||||
|
#export("body"):
|
||||||
|
Hello #(user.email)!
|
||||||
|
|
||||||
|
#if(contains(user.roles, "admin")):
|
||||||
|
<p><a href="#baseURL()/admin">Administration</a></p>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
<p><a href="#baseURL()/auth/logout">Log out</a></p>
|
||||||
|
#endexport
|
||||||
|
#endextend
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
struct UserAuthenticator<User: ManagedUser>: AsyncBasicAuthenticator where User.SessionID == ExpiringUserId {
|
||||||
|
|
||||||
|
func authenticate (basic: BasicAuthorization, for request: Request) async throws {
|
||||||
|
if let user = try await request.db.withSQLConnection { connection in
|
||||||
|
return try await User.find (email: basic.username, password: basic.password, on: connection)
|
||||||
|
} {
|
||||||
|
request.auth.login (user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
public struct UserSessionAuthenticator<SessionUser: ManagedUser>: AsyncSessionAuthenticator where SessionUser.SessionID == ExpiringUserId {
|
||||||
|
public typealias User = SessionUser
|
||||||
|
|
||||||
|
public func authenticate (sessionID: SessionUser.SessionID, for request: Request) async throws {
|
||||||
|
if let user = try await request.db.withSQLConnection { connection in
|
||||||
|
return try await SessionUser.authenticate (sessionID: sessionID, on: connection)
|
||||||
|
} {
|
||||||
|
request.logger.info ("Seeing user \(user)")
|
||||||
|
request.auth.login (user)
|
||||||
|
request.logger.info ("Saw user \(user)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import Vapor
|
||||||
|
import Leaf
|
||||||
|
import PostgresKit
|
||||||
|
import SwiftSMTPVapor
|
||||||
|
|
||||||
|
public struct AdminController<User: ManagedUser>: Sendable where User.SessionID == ExpiringUserId {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func routes (_ router: any RoutesBuilder, api apiRouter: any RoutesBuilder) {
|
||||||
|
let admin = router
|
||||||
|
.grouped ("admin")
|
||||||
|
.grouped (RoleMiddleware<User> (role: "admin"))
|
||||||
|
let api = apiRouter
|
||||||
|
.grouped ("admin")
|
||||||
|
.grouped (RoleAPIMiddleware<User> (role: "admin"))
|
||||||
|
|
||||||
|
admin.get (use: adminPage (request:))
|
||||||
|
api.get (use: list (request:))
|
||||||
|
api.get (":id", use: fetch (request:))
|
||||||
|
api.put (use: invite (request:))
|
||||||
|
api.post (":id", use: save(request:))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdminContext: Encodable {
|
||||||
|
let roles: [String]
|
||||||
|
let section: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable
|
||||||
|
private func adminPage (request: Request) async throws -> View {
|
||||||
|
let roles = try await request.db.withSQLConnection { connection in
|
||||||
|
return try await UserRole.all (connection: connection)
|
||||||
|
}
|
||||||
|
return try await request.view.render ("admin", AdminContext (roles: roles.map (\.name), section: "useradmin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserItem: Content {
|
||||||
|
let id: UUID
|
||||||
|
let email: String
|
||||||
|
let fullName: String
|
||||||
|
let isActive: Bool
|
||||||
|
let roles: [String]
|
||||||
|
|
||||||
|
init(user: User) {
|
||||||
|
self.id = user.id
|
||||||
|
self.email = user.email
|
||||||
|
self.fullName = user.fullName
|
||||||
|
self.isActive = user.isActive
|
||||||
|
self.roles = user.roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable
|
||||||
|
private func list (request: Request) async throws -> [UserItem] {
|
||||||
|
return try await request.db.withSQLConnection { connection in
|
||||||
|
let users = try await User.all(on: connection)
|
||||||
|
return users.map { UserItem (user: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable
|
||||||
|
private func fetch (request: Request) async throws -> UserItem {
|
||||||
|
guard let idString = request.parameters.get ("id"), let id = UUID (uuidString: idString) else {
|
||||||
|
throw Abort (.badRequest)
|
||||||
|
}
|
||||||
|
return try await request.db.withSQLConnection { connection in
|
||||||
|
guard let user = try await User.fetch (id, on: connection) else {
|
||||||
|
throw Abort (.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserItem (user: user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Invitation: Decodable {
|
||||||
|
let email: String
|
||||||
|
let fullname: String
|
||||||
|
let roles: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable
|
||||||
|
private func invite (request: Request) async throws -> Response {
|
||||||
|
|
||||||
|
let invitation = try request.content.decode(Invitation.self)
|
||||||
|
|
||||||
|
return try await request.db.withConnection { connection in
|
||||||
|
try await connection.transaction { connection in
|
||||||
|
let token = try await UserToken.create (connection: connection).token
|
||||||
|
try await User.create (email: invitation.email, fullname: invitation.fullname, roles: invitation.roles, token: token, on: connection)
|
||||||
|
let host = try Environment.baseURL.host() ?? ""
|
||||||
|
let body = try await request.view.render ("email/invite", ["token": token, "host": host])
|
||||||
|
.data
|
||||||
|
let message = Email (sender: Email.Contact (emailAddress: try Environment.emailSender),
|
||||||
|
recipients: [Email.Contact(emailAddress: invitation.email)],
|
||||||
|
subject: "Aktivera ditt konto på \(host)",
|
||||||
|
body: .plain (String (buffer: body)))
|
||||||
|
try await request.swiftSMTP.mailer.send(message)
|
||||||
|
|
||||||
|
return Response (status: .created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Save: Decodable {
|
||||||
|
let email: String
|
||||||
|
let fullname: String
|
||||||
|
let roles: [String]
|
||||||
|
let isActive: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable
|
||||||
|
private func save (request: Request) async throws -> Response {
|
||||||
|
|
||||||
|
guard let userId = request.parameters.get("id", as: UUID.self) else {
|
||||||
|
throw Abort (.badRequest)
|
||||||
|
}
|
||||||
|
let save = try request.content.decode(Save.self)
|
||||||
|
|
||||||
|
return try await request.db.withSQLConnection { connection in
|
||||||
|
guard (try await User.find (userId, on: connection)) != nil else {
|
||||||
|
throw Abort (.notFound)
|
||||||
|
}
|
||||||
|
guard !save.email.isEmpty && !save.fullname.isEmpty else {
|
||||||
|
throw Abort (.badRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await User.save (id: userId, email: save.email, fullname: save.fullname, roles: save.roles, isActive: save.isActive, on: connection)
|
||||||
|
|
||||||
|
return Response (status: .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import Vapor
|
||||||
|
import FluentPostgresDriver
|
||||||
|
import SwiftSMTP
|
||||||
|
|
||||||
|
public struct AuthenticationController<User: ManagedUser>: Sendable where User.SessionID == ExpiringUserId {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func routes (_ router: any RoutesBuilder, api apiRouter: any RoutesBuilder) {
|
||||||
|
let auth = router.grouped ("auth")
|
||||||
|
let api = apiRouter
|
||||||
|
.grouped ("auth")
|
||||||
|
|
||||||
|
auth.get ("login", use: loginForm)
|
||||||
|
api.post ("login", use: login)
|
||||||
|
auth.get ("password", ":token", use: passwordForm)
|
||||||
|
auth.grouped (LogoutMiddleware<User>()).get ("logout", use: logout)
|
||||||
|
api.get ("token", ":token", use: token)
|
||||||
|
api.post ("forgot", use: forgotPassword)
|
||||||
|
api.post ("password", ":token", use: savePassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginForm (request: Request) async throws -> View {
|
||||||
|
return try await request.view.render ("login", ["section": "login"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LoginInput: Decodable {
|
||||||
|
let email: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func login (request: Request) async throws -> some Response {
|
||||||
|
let loginInput = try request.content.decode (LoginInput.self)
|
||||||
|
|
||||||
|
return try await request.db.withSQLConnection { connection in
|
||||||
|
if let user = try await User.find (email: loginInput.email, password: loginInput.password, on: connection) {
|
||||||
|
request.auth.login (user)
|
||||||
|
request.logger.info ("Authenticated user \(user)")
|
||||||
|
|
||||||
|
return Response (status: .ok)
|
||||||
|
} else {
|
||||||
|
return Response (status: .unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordForm (request: Request) async throws -> View {
|
||||||
|
let token = try request.parameters.require ("token")
|
||||||
|
|
||||||
|
return try await request.view.render ("password", ["token": token, "section": "login"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout (request: Request) async throws -> some Response {
|
||||||
|
return request.redirect (to: try Environment.baseURL.absoluteString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func token (request: Request) async throws -> UserToken.Token {
|
||||||
|
let token = try request.parameters.require ("token")
|
||||||
|
|
||||||
|
return try await request.db.withSQLConnection { connection in
|
||||||
|
guard let token = try await UserToken.fetch (token: token, connection: connection) else {
|
||||||
|
throw Abort (.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NewPassword: Decodable {
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePassword (request: Request) async throws -> Response {
|
||||||
|
let token = try request.parameters.require ("token")
|
||||||
|
let newPassword = try request.content.decode (NewPassword.self)
|
||||||
|
|
||||||
|
return try await request.db.withConnection { connection in
|
||||||
|
try await connection.transaction { connection in
|
||||||
|
guard let userToken = try await UserToken.fetch (token: token, connection: connection) else {
|
||||||
|
throw Abort (.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await User.update (userId: userToken.userId, password: newPassword.password, on: connection)
|
||||||
|
try await UserToken.delete (token: token, connection: connection)
|
||||||
|
|
||||||
|
return Response(status: .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Input: Decodable {
|
||||||
|
let email: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgotPassword (request: Request) async throws -> Response {
|
||||||
|
|
||||||
|
let input = try request.content.decode (Input.self)
|
||||||
|
|
||||||
|
return try await request.db.withConnection { connection in
|
||||||
|
try await connection.transaction { connection in
|
||||||
|
if let user = try await User.find (email: input.email, on: connection) {
|
||||||
|
let token = try await UserToken.create (connection: connection).token
|
||||||
|
try await User.store (token: token, userId: user.id, on: connection)
|
||||||
|
let host = try Environment.baseURL.host() ?? ""
|
||||||
|
let body = try await request.view.render ("email/reset", ["token": token, "host": host, "section": "login"])
|
||||||
|
.data
|
||||||
|
let message = Email (sender: Email.Contact (emailAddress: try Environment.emailSender),
|
||||||
|
recipients: [Email.Contact(emailAddress: input.email)],
|
||||||
|
subject: "Aktivera ditt konto på \(host)",
|
||||||
|
body: .plain (String (buffer: body)))
|
||||||
|
try await request.swiftSMTP.mailer.send (message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(status: .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LogoutMiddleware<User: ManagedUser>: AsyncMiddleware {
|
||||||
|
func respond (to request: Vapor.Request, chainingTo next: any AsyncResponder) async throws -> Vapor.Response {
|
||||||
|
let response = try await next.respond (to: request)
|
||||||
|
|
||||||
|
request.auth.logout (User.self)
|
||||||
|
request.session.destroy()
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import Vapor
|
||||||
|
import Fluent
|
||||||
|
import FluentPostgresDriver
|
||||||
|
import SwiftSMTPVapor
|
||||||
|
|
||||||
|
public struct ManagableUsers {
|
||||||
|
public static func configure (_ app: Application, mainMenu: [MenuItem] = [], homePageName: String = "Home", userAdminPageName: String = "User Administration") async throws {
|
||||||
|
app.databases.use(DatabaseConfigurationFactory.postgres(configuration: .init(
|
||||||
|
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
|
||||||
|
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
|
||||||
|
username: Environment.get("DATABASE_USERNAME") ?? "sampleapp",
|
||||||
|
password: Environment.get("DATABASE_PASSWORD") ?? "sampleapp_password",
|
||||||
|
database: Environment.get("DATABASE_NAME") ?? "sampleapp",
|
||||||
|
tls: .prefer(try .init(configuration: .clientDefault)))
|
||||||
|
), as: .psql)
|
||||||
|
|
||||||
|
_ = try Environment.baseURL
|
||||||
|
_ = try Environment.emailSender
|
||||||
|
|
||||||
|
app.lifecycle.use(SMTPInitializer(configuration: .fromEnvironment()))
|
||||||
|
|
||||||
|
app.views.use(.leaf)
|
||||||
|
app.leaf.tags["baseURL"] = BaseURLTag()
|
||||||
|
app.leaf.tags["hasRole"] = HasRoleTag()
|
||||||
|
app.leaf.tags["mainmenuItems"] = MenuItemsTag (mainMenu + [MenuItem (section: "useradmin", path: "admin", name: userAdminPageName, role: "admin")], homePageName: homePageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
public struct RoleMiddleware<User: ManagedUser>: AsyncMiddleware {
|
||||||
|
private let role: String
|
||||||
|
|
||||||
|
public init (role: String) {
|
||||||
|
self.role = role
|
||||||
|
}
|
||||||
|
|
||||||
|
public func respond (to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
||||||
|
guard let user = request.auth.get (User.self),
|
||||||
|
user.roles.contains (role) else {
|
||||||
|
return request.redirect(to: try Environment.baseURL.absoluteString)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await next.respond (to: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RoleAPIMiddleware<User: ManagedUser>: AsyncMiddleware {
|
||||||
|
private let role: String
|
||||||
|
|
||||||
|
public init (role: String) {
|
||||||
|
self.role = role
|
||||||
|
}
|
||||||
|
|
||||||
|
public func respond (to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
||||||
|
let user = try request.auth.require (User.self)
|
||||||
|
guard user.roles.contains (role) else {
|
||||||
|
throw Abort (.forbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await next.respond (to: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import Vapor
|
||||||
|
import PostgresKit
|
||||||
|
import Crypto
|
||||||
|
|
||||||
|
// MARK: Defaults for Administration
|
||||||
|
extension ManagedUser {
|
||||||
|
|
||||||
|
public static func all (on connection: any SQLDatabase) async throws -> [Self] {
|
||||||
|
return try await connection.raw("""
|
||||||
|
SELECT "id",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"active",
|
||||||
|
ARRAY (SELECT "role_name"
|
||||||
|
FROM "user_roles"
|
||||||
|
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
|
||||||
|
FROM "users"
|
||||||
|
ORDER BY "email"
|
||||||
|
""")
|
||||||
|
.all (decoding: Self.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func fetch (_ id: UUID, on connection: any SQLDatabase) async throws -> Self? {
|
||||||
|
return try await connection.raw("""
|
||||||
|
SELECT "id",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"active",
|
||||||
|
"password",
|
||||||
|
ARRAY (SELECT "role_name"
|
||||||
|
FROM "user_roles"
|
||||||
|
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
|
||||||
|
FROM "users"
|
||||||
|
WHERE "id" = \(bind: id)
|
||||||
|
""")
|
||||||
|
.first (decoding: Self.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func create (email: String, fullname: String, roles: [String], token: String, on connection: any SQLDatabase) async throws {
|
||||||
|
if roles.isEmpty {
|
||||||
|
try await connection.raw("""
|
||||||
|
WITH created AS (
|
||||||
|
INSERT INTO "users" (
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"active")
|
||||||
|
VALUES (\(bind: email),
|
||||||
|
\(bind: fullname),
|
||||||
|
TRUE)
|
||||||
|
RETURNING "id"
|
||||||
|
)
|
||||||
|
INSERT INTO "user_tokens" ("user_id", "token")
|
||||||
|
SELECT "id", \(bind: token)
|
||||||
|
FROM created
|
||||||
|
""")
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
try await connection.raw("""
|
||||||
|
WITH "created" AS (
|
||||||
|
INSERT INTO "users" (
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"active")
|
||||||
|
VALUES (\(bind: email),
|
||||||
|
\(bind: fullname),
|
||||||
|
TRUE)
|
||||||
|
RETURNING "id"
|
||||||
|
),
|
||||||
|
"roles" AS (
|
||||||
|
INSERT INTO "user_roles" ("user_id", "role_name")
|
||||||
|
SELECT "id", "role_name"
|
||||||
|
FROM "created"
|
||||||
|
CROSS JOIN unnest (\(bind: roles)) AS "role_name"
|
||||||
|
)
|
||||||
|
INSERT INTO "user_tokens" ("user_id", "token")
|
||||||
|
SELECT "id", \(bind: token)
|
||||||
|
FROM "created"
|
||||||
|
""")
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func save (id: UUID, email: String, fullname: String, roles: [String], isActive: Bool, on connection: any SQLDatabase) async throws {
|
||||||
|
try await connection.raw ("""
|
||||||
|
WITH "update_user" AS (
|
||||||
|
UPDATE "users"
|
||||||
|
SET "email" = \(bind: email),
|
||||||
|
"full_name" = \(bind: fullname),
|
||||||
|
"active" = \(bind: isActive)
|
||||||
|
WHERE "id" = \(bind: id)
|
||||||
|
),
|
||||||
|
"desired_roles" AS (
|
||||||
|
SELECT "role_name"
|
||||||
|
FROM unnest (\(bind: roles)) "role_name"
|
||||||
|
),
|
||||||
|
"current_roles" AS (
|
||||||
|
SELECT "role_name"
|
||||||
|
FROM "user_roles"
|
||||||
|
WHERE "user_id" = \(bind: id)
|
||||||
|
),
|
||||||
|
"deleting_roles" AS (
|
||||||
|
DELETE FROM "user_roles"
|
||||||
|
WHERE "user_id" = \(bind: id)
|
||||||
|
AND NOT EXISTS (SELECT *
|
||||||
|
FROM "desired_roles"
|
||||||
|
WHERE "user_roles"."role_name" = "desired_roles"."role_name")
|
||||||
|
)
|
||||||
|
INSERT INTO "user_roles" ("user_id", "role_name")
|
||||||
|
SELECT \(bind: id), "name"
|
||||||
|
FROM "roles"
|
||||||
|
WHERE EXISTS (SELECT *
|
||||||
|
FROM "desired_roles"
|
||||||
|
WHERE "role_name" = "name")
|
||||||
|
AND NOT EXISTS (SELECT *
|
||||||
|
FROM "user_roles"
|
||||||
|
WHERE "user_id" = \(bind: id)
|
||||||
|
AND "role_name" = "name")
|
||||||
|
""")
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func store (token: String, userId: UUID, on connection: any SQLDatabase) async throws {
|
||||||
|
try await connection.raw("""
|
||||||
|
INSERT INTO "user_tokens" ("user_id", "token")
|
||||||
|
VALUES (\(bind: userId), \(bind: token))
|
||||||
|
""")
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BasicUser: ManagedUser {
|
||||||
|
public typealias SessionID = ExpiringUserId
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let email: String
|
||||||
|
public let fullName: String
|
||||||
|
public let password: String?
|
||||||
|
public let roles: [String]
|
||||||
|
public let isActive: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case email
|
||||||
|
case fullName = "full_name"
|
||||||
|
case password
|
||||||
|
case roles
|
||||||
|
case isActive = "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ExpiringUserId: LosslessStringConvertible, Codable, Sendable {
|
||||||
|
|
||||||
|
static let expirationInterval: TimeInterval = 60.0 * 60.0
|
||||||
|
let id: UUID
|
||||||
|
let expiration: Date
|
||||||
|
|
||||||
|
init (user: any ManagedUser) {
|
||||||
|
id = user.id
|
||||||
|
expiration = Date (timeIntervalSinceNow: ExpiringUserId.expirationInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init? (_ description: String) {
|
||||||
|
if let data = Data (base64Encoded: description) {
|
||||||
|
do {
|
||||||
|
self = try JSONDecoder().decode (ExpiringUserId.self, from: data)
|
||||||
|
if expiration.timeIntervalSinceNow < 0.0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
do {
|
||||||
|
return try JSONEncoder().encode (self).base64EncodedString()
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import Vapor
|
||||||
|
import SQLKit
|
||||||
|
import Crypto
|
||||||
|
|
||||||
|
public protocol ManagedUser: Content, SessionAuthenticatable {
|
||||||
|
var id: UUID { get }
|
||||||
|
var email: String { get }
|
||||||
|
var fullName: String { get }
|
||||||
|
var password: String? { get }
|
||||||
|
var roles: [String] { get }
|
||||||
|
var isActive: Bool { get }
|
||||||
|
|
||||||
|
// MARK: - Authentication
|
||||||
|
static func find (_ id: UUID, on connection: any SQLDatabase) async throws -> Self?
|
||||||
|
static func find (email: String, password: String, on connection: any SQLDatabase) async throws -> Self?
|
||||||
|
static func find (email: String, on connection: any SQLDatabase) async throws -> Self?
|
||||||
|
static func update (userId: UUID, password: String, on connection: any SQLDatabase) async throws
|
||||||
|
|
||||||
|
// MARK: - Administration
|
||||||
|
static func all (on connection: any SQLDatabase) async throws -> [Self]
|
||||||
|
static func create (email: String, fullname: String, roles: [String], token: String, on connection: any SQLDatabase) async throws
|
||||||
|
static func save (id: UUID, email: String, fullname: String, roles: [String], isActive: Bool, on connection: any SQLDatabase) async throws
|
||||||
|
static func store (token: String, userId: UUID, on connection: any SQLDatabase) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ManagedUser where SessionID == ExpiringUserId {
|
||||||
|
|
||||||
|
public var sessionID: ExpiringUserId {
|
||||||
|
return ExpiringUserId (user: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static func encrypt (password: String) throws -> String {
|
||||||
|
return try Bcrypt.hash (password)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func authenticate (sessionID: SessionID, on connection: any SQLDatabase) async throws -> Self? {
|
||||||
|
return try await find (sessionID.id, on: connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verify (password: String) throws -> Bool {
|
||||||
|
if let userPassword = self.password {
|
||||||
|
return try Bcrypt.verify (password, created: userPassword)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func sessionAuthenticator() -> UserSessionAuthenticator<Self> {
|
||||||
|
return UserSessionAuthenticator()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func basicAuthenticator() -> UserAuthenticator<Self> {
|
||||||
|
return UserAuthenticator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Defaults for Authentication
|
||||||
|
extension ManagedUser where SessionID == ExpiringUserId {
|
||||||
|
public static func find (_ id: UUID, on connection: any SQLDatabase) async throws -> Self? {
|
||||||
|
return try await connection.raw("""
|
||||||
|
SELECT "id",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"active",
|
||||||
|
"password",
|
||||||
|
ARRAY (SELECT "role_name"
|
||||||
|
FROM "user_roles"
|
||||||
|
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
|
||||||
|
FROM "users"
|
||||||
|
WHERE "id" = \(bind: id)
|
||||||
|
AND "active"
|
||||||
|
""")
|
||||||
|
.first (decoding: Self.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func find (email: String, password: String, on connection: any SQLDatabase) async throws -> Self? {
|
||||||
|
if let user = try await find (email: email, on: connection),
|
||||||
|
try user.verify (password: password) {
|
||||||
|
return user
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func find (email: String, on connection: any SQLDatabase) async throws -> Self? {
|
||||||
|
return try await connection.raw ("""
|
||||||
|
SELECT "id",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"active",
|
||||||
|
"password",
|
||||||
|
ARRAY (SELECT "role_name"
|
||||||
|
FROM "user_roles"
|
||||||
|
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
|
||||||
|
FROM "users"
|
||||||
|
WHERE "email" = \(bind: email)
|
||||||
|
AND "active"
|
||||||
|
""")
|
||||||
|
.first (decoding: Self.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func update (userId: UUID, password: String, on connection: any SQLDatabase) async throws {
|
||||||
|
try await connection.raw ("""
|
||||||
|
UPDATE "users"
|
||||||
|
SET "password" = \(bind: Self.encrypt (password: password))
|
||||||
|
WHERE "id" = \(bind: userId)
|
||||||
|
""")
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Foundation
|
||||||
|
import PostgresKit
|
||||||
|
|
||||||
|
struct UserRole: Codable {
|
||||||
|
let name: String
|
||||||
|
|
||||||
|
static func all (connection: any SQLDatabase) async throws -> [UserRole] {
|
||||||
|
return try await connection.raw ("""
|
||||||
|
SELECT "name"
|
||||||
|
FROM "roles"
|
||||||
|
ORDER BY "name"
|
||||||
|
""")
|
||||||
|
.all (decoding: UserRole.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import SQLKit
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
struct UserToken: Decodable {
|
||||||
|
let token: String
|
||||||
|
|
||||||
|
static func create (connection: any SQLDatabase) async throws -> UserToken {
|
||||||
|
return try await connection.raw ("""
|
||||||
|
SELECT string_agg (substr (c, (random() * length (c) + 1)::integer, 1), '') AS "token"
|
||||||
|
FROM (VALUES ('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')) AS x(c),
|
||||||
|
generate_series (1, 32)
|
||||||
|
""")
|
||||||
|
.first (decoding: UserToken.self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Token: Content {
|
||||||
|
let userId: UUID
|
||||||
|
let email: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userId = "user_id"
|
||||||
|
case email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fetch (token: String, connection: any SQLDatabase) async throws -> Token? {
|
||||||
|
return try await connection.raw ("""
|
||||||
|
SELECT "user_id", "email"
|
||||||
|
FROM "user_tokens"
|
||||||
|
JOIN "users"
|
||||||
|
ON "users"."id" = "user_tokens"."user_id"
|
||||||
|
WHERE "token" = \(bind: token)
|
||||||
|
AND "insert_time" >= CURRENT_TIMESTAMP - INTERVAL '1 HOUR'
|
||||||
|
""")
|
||||||
|
.first (decoding: Token.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete (token: String, connection: any SQLDatabase) async throws {
|
||||||
|
return try await connection.raw ("""
|
||||||
|
DELETE FROM "user_tokens"
|
||||||
|
WHERE "token" = \(bind: token)
|
||||||
|
""")
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Leaf
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
final class BaseURLTag: LeafTag {
|
||||||
|
init() { }
|
||||||
|
|
||||||
|
func render (_ context: LeafContext) throws -> LeafData {
|
||||||
|
return LeafData.string (try Environment.baseURL.absoluteString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
extension Environment {
|
||||||
|
enum Error: Swift.Error, LocalizedError {
|
||||||
|
case missing (String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missing (let variable):
|
||||||
|
return "Missing environment variable: \(variable)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var baseURL: URL {
|
||||||
|
get throws {
|
||||||
|
guard let baseURL = Environment.get ("BASE_URL"), let url = URL (string: baseURL) else {
|
||||||
|
throw Error.missing ("BASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func baseURL (_ path: String) throws -> URL {
|
||||||
|
return try baseURL.appendingPathComponent (path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var emailSender: String {
|
||||||
|
get throws {
|
||||||
|
guard let sender = Environment.get ("EMAIL_SENDER") else {
|
||||||
|
throw Error.missing ("EMAIL_SENDER")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import LeafKit
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
struct HasRoleTag: LeafTag {
|
||||||
|
func render (_ context: LeafContext) throws -> LeafData {
|
||||||
|
try context.requireParameterCount (1)
|
||||||
|
let roles = context.request?.auth.get (BasicUser.self)?.roles ?? []
|
||||||
|
|
||||||
|
if let string = context.parameters[0].string {
|
||||||
|
return LeafData.bool (roles.contains (string))
|
||||||
|
} else {
|
||||||
|
throw LeafError (.unknownError ("Unable to convert role parameter to LeafData string"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import Leaf
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
public struct MenuItem {
|
||||||
|
let section: String
|
||||||
|
let path: String?
|
||||||
|
let name: String
|
||||||
|
let role: String?
|
||||||
|
|
||||||
|
public init (section: String, path: String?, name: String, role: String? = nil) {
|
||||||
|
self.section = section
|
||||||
|
self.path = path
|
||||||
|
self.name = name
|
||||||
|
self.role = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class MenuItemsTag: UnsafeUnescapedLeafTag {
|
||||||
|
let items: [MenuItem]
|
||||||
|
|
||||||
|
init (_ items: [MenuItem], homePageName: String = "Home") {
|
||||||
|
self.items = [MenuItem (section: "home", path: nil, name: homePageName)] + items
|
||||||
|
}
|
||||||
|
|
||||||
|
func render (_ context: LeafContext) throws -> LeafData {
|
||||||
|
let baseURL = try Environment.baseURL.absoluteString
|
||||||
|
let roles = context.request?.auth.get (BasicUser.self)?.roles ?? []
|
||||||
|
let section = context.parameters.first?.string
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
if item.role == nil || roles.contains (item.role!) {
|
||||||
|
if let path = item.path {
|
||||||
|
lines.append (#" <a href="\#(baseURL)/\#(path)"\#(activeClass (section, item.section))>\#(item.name)</a>"#)
|
||||||
|
} else {
|
||||||
|
lines.append (#" <a href="\#(baseURL)"\#(activeClass (section, item.section))>\#(item.name)</a>"#)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LeafData.string (lines.joined (separator: "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activeClass (_ section: String?, _ expected: String) -> String {
|
||||||
|
if section == expected {
|
||||||
|
#" class="active""#
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import SQLKit
|
||||||
|
import Logging
|
||||||
|
import NIOCore
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
public class MockDatabase: @unchecked Sendable {
|
||||||
|
public let logger: Logger = Logger (label: "MockDatabase")
|
||||||
|
public let eventLoop: any EventLoop
|
||||||
|
public var queries = [any SQLExpression]()
|
||||||
|
public var results = [[any SQLRow]]()
|
||||||
|
|
||||||
|
public init (eventLoop: any EventLoop) {
|
||||||
|
self.eventLoop = eventLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
func withConnection<T> (_ body: (any SQLDatabase) async throws -> T) async rethrows -> T {
|
||||||
|
return try await body (self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public let dialect: any SQLDialect = Dialect()
|
||||||
|
|
||||||
|
struct Dialect: SQLDialect {
|
||||||
|
let name = "MockDatabase"
|
||||||
|
let identifierQuote: any SQLExpression = SQLRaw(#"""#)
|
||||||
|
let supportsAutoIncrement = false
|
||||||
|
let autoIncrementClause: any SQLExpression = SQLRaw("GENERATED BY DEFAULT AS IDENTITY")
|
||||||
|
|
||||||
|
func bindPlaceholder(at position: Int) -> any SQLExpression {
|
||||||
|
SQLRaw("$\(position)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func literalBoolean(_ value: Bool) -> any SQLKit.SQLExpression {
|
||||||
|
SQLRaw("\(value)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MockDatabaseErrors: Error {
|
||||||
|
case empty
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MockDatabase: SQLDatabase {
|
||||||
|
|
||||||
|
public func execute (sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture<Void> {
|
||||||
|
self.queries.append (query)
|
||||||
|
|
||||||
|
guard !results.isEmpty else {
|
||||||
|
return eventLoop.makeFailedFuture (MockDatabaseErrors.empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
for try row in results.removeFirst() {
|
||||||
|
onRow (row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventLoop.makeSucceededFuture(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MockDatabase {
|
||||||
|
public struct Row: SQLRow {
|
||||||
|
public let values: [String: any Sendable]
|
||||||
|
|
||||||
|
public init(values: [String : any Sendable]) {
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
|
||||||
|
public var allColumns: [String] { values.keys.sorted() }
|
||||||
|
public func contains (column: String) -> Bool {
|
||||||
|
values.keys.contains (column)
|
||||||
|
}
|
||||||
|
public func decodeNil (column: String) throws -> Bool { !values.keys.contains (column) }
|
||||||
|
|
||||||
|
public func decode<D> (column: String, as: D.Type) throws -> D where D : Decodable {
|
||||||
|
guard let value = values[column] as? D else {
|
||||||
|
throw DecodingError.dataCorrupted (DecodingError.Context (codingPath: [], debugDescription: "Cannot decode \(D.self) from \(String (describing: self))"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Application {
|
||||||
|
|
||||||
|
protocol MockDatabaseConnectionKey: StorageKey where Value == MockDatabase {}
|
||||||
|
|
||||||
|
public struct MockDatabaseKey: MockDatabaseConnectionKey {
|
||||||
|
public typealias Value = MockDatabase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper around the errors that can occur during a transaction.
|
||||||
|
public struct TransactionError: Error {
|
||||||
|
|
||||||
|
/// The file in which the transaction was started
|
||||||
|
public var file: String
|
||||||
|
/// The line in which the transaction was started
|
||||||
|
public var line: Int
|
||||||
|
|
||||||
|
/// The error thrown when running the `BEGIN` query
|
||||||
|
public var beginError: (any Error)?
|
||||||
|
/// The error thrown in the transaction closure
|
||||||
|
public var closureError: (any Error)?
|
||||||
|
|
||||||
|
/// The error thrown while rolling the transaction back. If the ``closureError`` is set,
|
||||||
|
/// but the ``rollbackError`` is empty, the rollback was successful. If the ``rollbackError``
|
||||||
|
/// is set, the rollback failed.
|
||||||
|
public var rollbackError: (any Error)?
|
||||||
|
|
||||||
|
/// The error thrown while commiting the transaction.
|
||||||
|
public var commitError: (any Error)?
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import Fluent
|
||||||
|
import SQLKit
|
||||||
|
|
||||||
|
extension Database {
|
||||||
|
public func withSQLConnection<T: Sendable>(_ closure: @escaping @Sendable (SQLDatabase) async throws -> T) async throws -> T {
|
||||||
|
return try await self.withConnection { database in
|
||||||
|
guard let connection = database as? SQLDatabase else {
|
||||||
|
throw NoSQLDatabaseError (database: database)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await closure (connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import FluentKit
|
||||||
|
import SQLKit
|
||||||
|
|
||||||
|
extension Database {
|
||||||
|
public func transaction<T: Sendable>(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> T) async throws -> T {
|
||||||
|
guard let database = self as? SQLDatabase else {
|
||||||
|
throw NoSQLDatabaseError (database: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await self.transaction { (_: any Database) in
|
||||||
|
try await closure (database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoSQLDatabaseError: Error, CustomDebugStringConvertible {
|
||||||
|
let database: any Database
|
||||||
|
|
||||||
|
var debugDescription: String { "\(database) is not a `SQLDatabase`" }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import ManagableUsers
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
public func configure (_ app: Application) async throws {
|
||||||
|
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
|
||||||
|
|
||||||
|
try await ManagableUsers.configure (app)
|
||||||
|
|
||||||
|
try routes(app)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import Vapor
|
||||||
|
import Logging
|
||||||
|
import NIOCore
|
||||||
|
import NIOPosix
|
||||||
|
|
||||||
|
@main
|
||||||
|
enum Entrypoint {
|
||||||
|
static func main() async throws {
|
||||||
|
var env = try Environment.detect()
|
||||||
|
try LoggingSystem.bootstrap(from: &env)
|
||||||
|
|
||||||
|
let app = try await Application.make(env)
|
||||||
|
|
||||||
|
// This attempts to install NIO as the Swift Concurrency global executor.
|
||||||
|
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
|
||||||
|
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
|
||||||
|
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
|
||||||
|
let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
|
||||||
|
app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await configure(app)
|
||||||
|
try await app.execute()
|
||||||
|
} catch {
|
||||||
|
app.logger.report(error: error)
|
||||||
|
try? await app.asyncShutdown()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try await app.asyncShutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import ManagableUsers
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
func routes(_ app: Application) throws {
|
||||||
|
let sessioned = app.grouped (app.sessions.middleware)
|
||||||
|
.grouped (BasicUser.sessionAuthenticator())
|
||||||
|
let loggedIn = sessioned
|
||||||
|
.grouped (BasicUser.redirectMiddleware (path: "auth/login"))
|
||||||
|
let api = sessioned
|
||||||
|
.grouped("api")
|
||||||
|
|
||||||
|
AuthenticationController<BasicUser>().routes (sessioned, api: api)
|
||||||
|
AdminController<BasicUser>().routes (loggedIn, api: api)
|
||||||
|
|
||||||
|
loggedIn.get { req async throws in
|
||||||
|
try await req.view.render("welcome", ["user": req.auth.get (BasicUser.self)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import ManagableUsers
|
||||||
|
@testable import SampleApp
|
||||||
|
import VaporTesting
|
||||||
|
import SQLKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite("App Tests", .serialized)
|
||||||
|
struct AdminTests {
|
||||||
|
private func withApp(_ test: (Application) async throws -> ()) async throws {
|
||||||
|
let app = try await Application.make (.testing)
|
||||||
|
do {
|
||||||
|
try await SampleApp.configure (app)
|
||||||
|
let mockDatabase = MockDatabase (eventLoop: app.eventLoopGroup.next())
|
||||||
|
app.storage[Application.MockDatabaseKey.self] = mockDatabase
|
||||||
|
try await test (app)
|
||||||
|
} catch {
|
||||||
|
try await app.asyncShutdown()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try await app.asyncShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unauthenticated list")
|
||||||
|
func unauthenticatedList() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
try await app.testing().test(
|
||||||
|
.GET,
|
||||||
|
"api/admin",
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .unauthorized)
|
||||||
|
#expect(res.headers["Location"].isEmpty)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unauthorized list")
|
||||||
|
func unauthorizedList() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
var session: String?
|
||||||
|
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
|
||||||
|
|
||||||
|
try await app.testing().test(
|
||||||
|
.POST,
|
||||||
|
"api/auth/login",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.contentType = .urlEncodedForm
|
||||||
|
request.body = ByteBuffer (string: "email=gamma&password=")
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"] != nil)
|
||||||
|
session = res.headers.setCookie?["vapor-session"]?.string
|
||||||
|
})
|
||||||
|
|
||||||
|
try await app.testing().test(.GET, "api/admin",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .forbidden)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Authorized list")
|
||||||
|
func authorizedList() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
var session: String?
|
||||||
|
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "active": true, "roles": ["admin"]])])
|
||||||
|
|
||||||
|
try await app.testing().test(
|
||||||
|
.POST,
|
||||||
|
"api/auth/login",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.contentType = .urlEncodedForm
|
||||||
|
request.body = ByteBuffer (string: "email=gamma&password=")
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"] != nil)
|
||||||
|
session = res.headers.setCookie?["vapor-session"]?.string
|
||||||
|
})
|
||||||
|
|
||||||
|
try await app.testing().test(.GET, "api/admin",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
|
||||||
|
#expect(res.headers.contentType == .json)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
@testable import SampleApp
|
||||||
|
import ManagableUsers
|
||||||
|
import VaporTesting
|
||||||
|
import SQLKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite("App Tests", .serialized)
|
||||||
|
struct AuthorizationTests {
|
||||||
|
private func withApp(_ test: (Application) async throws -> ()) async throws {
|
||||||
|
let app = try await Application.make (.testing)
|
||||||
|
do {
|
||||||
|
try await SampleApp.configure (app)
|
||||||
|
let mockDatabase = MockDatabase (eventLoop: app.eventLoopGroup.next())
|
||||||
|
app.storage[Application.MockDatabaseKey.self] = mockDatabase
|
||||||
|
try await test (app)
|
||||||
|
} catch {
|
||||||
|
try await app.asyncShutdown()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try await app.asyncShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Test login redirect")
|
||||||
|
func loginRedirect() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
try await app.testing().test(.GET, "", afterResponse: { res async in
|
||||||
|
#expect(res.status == .seeOther)
|
||||||
|
#expect(res.headers["Location"] == ["auth/login"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Test login form")
|
||||||
|
func loginForm() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
try await app.testing().test(.GET, "auth/login", afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Invalid login")
|
||||||
|
func invalidLogin() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
|
||||||
|
|
||||||
|
try await app.testing().test(
|
||||||
|
.POST,
|
||||||
|
"api/auth/login",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.contentType = .json
|
||||||
|
request.body = ByteBuffer (string: #"{"email": "foo", "password": "bar"}"#)
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .unauthorized)
|
||||||
|
#expect(res.headers["Location"].isEmpty)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Valid login")
|
||||||
|
func validLogin() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
var session: String?
|
||||||
|
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
|
||||||
|
|
||||||
|
try await app.testing().test(
|
||||||
|
.POST,
|
||||||
|
"api/auth/login",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.contentType = .json
|
||||||
|
request.body = ByteBuffer (string: #"{"email": "gamma", "password": ""}"#)
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers["Location"].isEmpty)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"] != nil)
|
||||||
|
session = res.headers.setCookie?["vapor-session"]?.string
|
||||||
|
})
|
||||||
|
|
||||||
|
try await app.testing().test(.GET, "",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Restricted route")
|
||||||
|
func restrictedRoute() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
var session: String?
|
||||||
|
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
|
||||||
|
|
||||||
|
try await app.testing().test(
|
||||||
|
.POST,
|
||||||
|
"api/auth/login",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.contentType = .json
|
||||||
|
request.body = ByteBuffer (string: #"{"email": "gamma", "password": ""}"#)
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"] != nil)
|
||||||
|
session = res.headers.setCookie?["vapor-session"]?.string
|
||||||
|
})
|
||||||
|
|
||||||
|
try await app.testing().test(.GET, "admin",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .seeOther)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
|
||||||
|
#expect(res.headers["Location"] == ["http://localhost:8080"])
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Restricted authorized route")
|
||||||
|
func restrictedAuthorizedRoute() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
var session: String?
|
||||||
|
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["name": "admin"])])
|
||||||
|
|
||||||
|
try await app.testing().test(
|
||||||
|
.POST,
|
||||||
|
"api/auth/login",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.contentType = .json
|
||||||
|
request.body = ByteBuffer (string: #"{"email": "gamma", "password": ""}"#)
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"] != nil)
|
||||||
|
session = res.headers.setCookie?["vapor-session"]?.string
|
||||||
|
})
|
||||||
|
|
||||||
|
try await app.testing().test(.GET, "admin",
|
||||||
|
beforeRequest: { request async in
|
||||||
|
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Forgot password route")
|
||||||
|
func forgotPasswordRoute() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "a@b", "full_name": "Somebody", "password": "", "active": true, "roles": [""]])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["token": "füffe"])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
|
||||||
|
|
||||||
|
try await app.testing().test(.POST, "api/auth/forgot",
|
||||||
|
beforeRequest: { request async throws in
|
||||||
|
try request.content.encode (["email": "gamma"])
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Forgot password route without account")
|
||||||
|
func forgotPasswordRouteWithoutAccount() async throws {
|
||||||
|
try await withApp { app in
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([])
|
||||||
|
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["token": "füffe"])])
|
||||||
|
|
||||||
|
try await app.testing().test(.POST, "api/auth/forgot",
|
||||||
|
beforeRequest: { request async throws in
|
||||||
|
try request.content.encode (["email": "gamma"])
|
||||||
|
},
|
||||||
|
afterResponse: { res async in
|
||||||
|
#expect(res.status == .ok)
|
||||||
|
})
|
||||||
|
|
||||||
|
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue