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