Initial commit
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.npm-cache/*
|
||||||
|
client/.angular/cache/*
|
||||||
|
.env
|
||||||
|
server/server/data/privatechat.sqlite
|
||||||
|
server/server/data/privatechat.sqlite-shm
|
||||||
|
server/server/data/privatechat.sqlite-wal
|
||||||
|
server/server/data/master.key
|
||||||
|
client/dist/*
|
||||||
99
README.md
Normal file
99
README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# PrivateChat
|
||||||
|
|
||||||
|
PrivateChat is a two-part application:
|
||||||
|
|
||||||
|
- An Angular 21 + Bootstrap frontend for authentication, peer discovery, and direct chat.
|
||||||
|
- A Fastify + WebSocket backend for JWT-based authentication, Redis-backed sessions, and initial WebRTC signaling.
|
||||||
|
|
||||||
|
Once two clients discover each other through the backend, text, JSON payloads, and file transfers move over a direct WebRTC data channel. Signaling is only allowed for clients with an active authenticated backend session.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Register and log in with JWTs that are bound to active Redis sessions.
|
||||||
|
- Sign in with either a password or a registered WebAuthn access key.
|
||||||
|
- Persist users in a local SQLite database with encrypted credential storage.
|
||||||
|
- Persist the JWT signing secret in the SQLite database, encrypted at rest.
|
||||||
|
- Allow authenticated users to register multiple WebAuthn access keys.
|
||||||
|
- Discover online peers through a signaling WebSocket.
|
||||||
|
- Establish direct WebRTC peer connections.
|
||||||
|
- Exchange plain text messages, structured JSON payloads, and files.
|
||||||
|
- Keep signaling on the backend and user content on the peer-to-peer data channel.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Node.js 20+ recommended.
|
||||||
|
- npm 10+.
|
||||||
|
- A local Redis instance reachable at `redis://127.0.0.1:6379/0` by default.
|
||||||
|
|
||||||
|
Install the root runner dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Start both apps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration defaults now live in the repo root [`.env`](/var/approot/PrivateChat/.env). The backend loads that file with `dotenv` at startup, and the frontend generates `client/public/env.js` from the same file before `ng serve` and `ng build`.
|
||||||
|
|
||||||
|
To serve the prebuilt Angular app from Fastify and run only one server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start --prefix server
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend serves `client/dist/client/browser` on the same origin as the API and WebSocket endpoints.
|
||||||
|
|
||||||
|
Default endpoints:
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:4200`
|
||||||
|
- Backend: `http://localhost:3000`
|
||||||
|
|
||||||
|
The backend automatically creates:
|
||||||
|
|
||||||
|
- `server/data/privatechat.sqlite` for users and encrypted secret material.
|
||||||
|
- `server/data/master.key` if `PRIVATECHAT_MASTER_KEY` is not supplied.
|
||||||
|
|
||||||
|
The frontend lets you override the backend URL from the login screen if you need a different host or port.
|
||||||
|
|
||||||
|
## Apple client
|
||||||
|
|
||||||
|
The repo also includes a multiplatform SwiftUI client in `apple-client/` for macOS and iOS/iPadOS.
|
||||||
|
|
||||||
|
- The native Settings UI manages backend URL, password auth, passkey auth, and access-key registration.
|
||||||
|
- The main chat surface embeds the Angular client bundle in a WKWebView, so the WebRTC chat workspace stays aligned with the web app.
|
||||||
|
- Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`.
|
||||||
|
- A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it.
|
||||||
|
|
||||||
|
## Backend environment
|
||||||
|
|
||||||
|
The backend accepts these environment variables:
|
||||||
|
|
||||||
|
- `PORT`: HTTP and WebSocket port. Default `3000`.
|
||||||
|
- `REDIS_URL`: Local Redis connection string. Default `redis://127.0.0.1:6379/0`.
|
||||||
|
- `SESSION_TTL_SECONDS`: Redis session TTL. Default `43200`.
|
||||||
|
- `SQLITE_PATH`: Optional SQLite database path.
|
||||||
|
- `PRIVATECHAT_DATA_DIR`: Base directory for generated local storage.
|
||||||
|
- `PRIVATECHAT_MASTER_KEY`: Optional master key for encrypting SQLite secret material and user credentials.
|
||||||
|
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key.
|
||||||
|
- `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`.
|
||||||
|
- `CORS_ORIGIN`: Optional allowed browser origin. If omitted, the server reflects request origins.
|
||||||
|
- `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`.
|
||||||
|
- `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
|
||||||
|
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.
|
||||||
|
- `WEBAUTHN_USER_VERIFICATION`: WebAuthn user-verification policy for access-key registration. Supported values: `discouraged`, `preferred`, `required`. Default `preferred`.
|
||||||
|
- `WEBAUTHN_CHALLENGE_TTL_SECONDS`: Pending access-key registration challenge TTL in Redis. Default `300`.
|
||||||
|
|
||||||
|
## Frontend environment
|
||||||
|
|
||||||
|
- `PRIVATECHAT_CLIENT_SERVER_URL`: Default backend URL preloaded into the Angular app before local storage overrides apply.
|
||||||
|
|
||||||
|
## Production note
|
||||||
|
|
||||||
|
This project uses the Fastify server only for auth and signaling. The chat payload itself stays peer-to-peer over WebRTC. For production deployment, replace the local master key strategy with your preferred secret-management system, keep Redis durable enough for your session policy, and configure TURN infrastructure if peers cannot connect with STUN alone.
|
||||||
381
apple-client/PrivateChatApple.xcodeproj/project.pbxproj
Normal file
381
apple-client/PrivateChatApple.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
0CF99BBF9D1A04B9E2F5DFC8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A07E2A8E4DEFCE88D5553B5B /* ContentView.swift */; };
|
||||||
|
1A645AC02B000D539C39368C /* PasskeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FF93F2A1FD25DB9E957AA6 /* PasskeyManager.swift */; };
|
||||||
|
3B90CADE868971A576AE57A7 /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E71E386D6B12B015DC0103 /* AppModels.swift */; };
|
||||||
|
3BEB977AA98055EF05F97989 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977F5A04FC063DC80BB9CE26 /* SettingsView.swift */; };
|
||||||
|
861A055BD65866A57B6BBC0E /* EmbeddedWebAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09649C45C63E1AD1704C0D78 /* EmbeddedWebAppView.swift */; };
|
||||||
|
D02DE21893F9BFC1519D511C /* BackendClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591916AA72EF25C5E6F3CCBB /* BackendClient.swift */; };
|
||||||
|
E854B030B8ACED01056B39FD /* PrivateChatAppleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8901583248E9FADEEB357 /* PrivateChatAppleApp.swift */; };
|
||||||
|
FD0B1329E00BFC563CA92B34 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8826CE950551697F8A2238FD /* SettingsStore.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
08E8FBDE6277D997AAD09FFE /* PrivateChatApple.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = PrivateChatApple.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
09649C45C63E1AD1704C0D78 /* EmbeddedWebAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedWebAppView.swift; sourceTree = "<group>"; };
|
||||||
|
16E71E386D6B12B015DC0103 /* AppModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModels.swift; sourceTree = "<group>"; };
|
||||||
|
38FF93F2A1FD25DB9E957AA6 /* PasskeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyManager.swift; sourceTree = "<group>"; };
|
||||||
|
591916AA72EF25C5E6F3CCBB /* BackendClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendClient.swift; sourceTree = "<group>"; };
|
||||||
|
8826CE950551697F8A2238FD /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
||||||
|
977F5A04FC063DC80BB9CE26 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
A07E2A8E4DEFCE88D5553B5B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
FDF8901583248E9FADEEB357 /* PrivateChatAppleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatAppleApp.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
63815A93152EA7A84CECAE92 /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
16E71E386D6B12B015DC0103 /* AppModels.swift */,
|
||||||
|
591916AA72EF25C5E6F3CCBB /* BackendClient.swift */,
|
||||||
|
A07E2A8E4DEFCE88D5553B5B /* ContentView.swift */,
|
||||||
|
09649C45C63E1AD1704C0D78 /* EmbeddedWebAppView.swift */,
|
||||||
|
38FF93F2A1FD25DB9E957AA6 /* PasskeyManager.swift */,
|
||||||
|
FDF8901583248E9FADEEB357 /* PrivateChatAppleApp.swift */,
|
||||||
|
8826CE950551697F8A2238FD /* SettingsStore.swift */,
|
||||||
|
977F5A04FC063DC80BB9CE26 /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
name = App;
|
||||||
|
path = Sources/App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
66E649CB0A00080FE1C2DC25 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
08E8FBDE6277D997AAD09FFE /* PrivateChatApple.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CBDB6BA6F0BD33470718D8EF = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
63815A93152EA7A84CECAE92 /* App */,
|
||||||
|
66E649CB0A00080FE1C2DC25 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
3438777E81C60FEE7970CAF2 /* PrivateChatApple */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 03EBB318C449F01FB0639745 /* Build configuration list for PBXNativeTarget "PrivateChatApple" */;
|
||||||
|
buildPhases = (
|
||||||
|
473BDD4E02B992BD309F5028 /* Build Embedded Angular Client */,
|
||||||
|
0B1A447B1617CF6E1E4F4EA9 /* Sources */,
|
||||||
|
04166E69061744AF9B27795E /* Copy Embedded Angular Client */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = PrivateChatApple;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = PrivateChatApple;
|
||||||
|
productReference = 08E8FBDE6277D997AAD09FFE /* PrivateChatApple.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
12B40BE574717A46063F45C8 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1430;
|
||||||
|
TargetAttributes = {
|
||||||
|
3438777E81C60FEE7970CAF2 = {
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = A55C1DFA666ADF74C56D34DA /* Build configuration list for PBXProject "PrivateChatApple" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = CBDB6BA6F0BD33470718D8EF;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
3438777E81C60FEE7970CAF2 /* PrivateChatApple */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
04166E69061744AF9B27795E /* Copy Embedded Angular Client */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Copy Embedded Angular Client";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "set -euo pipefail\nDESTINATION=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/WebApp\"\nrm -rf \"$DESTINATION\"\nmkdir -p \"$DESTINATION\"\ncp -R \"$SRCROOT/WebApp/.\" \"$DESTINATION\"\n";
|
||||||
|
};
|
||||||
|
473BDD4E02B992BD309F5028 /* Build Embedded Angular Client */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Build Embedded Angular Client";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "set -euo pipefail\ncd \"$SRCROOT/..\"\nmkdir -p \"$SRCROOT/WebApp\"\nnpm run build --prefix client -- --base-href ./ --output-path \"$SRCROOT/WebApp\"\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
0B1A447B1617CF6E1E4F4EA9 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
3B90CADE868971A576AE57A7 /* AppModels.swift in Sources */,
|
||||||
|
D02DE21893F9BFC1519D511C /* BackendClient.swift in Sources */,
|
||||||
|
0CF99BBF9D1A04B9E2F5DFC8 /* ContentView.swift in Sources */,
|
||||||
|
861A055BD65866A57B6BBC0E /* EmbeddedWebAppView.swift in Sources */,
|
||||||
|
1A645AC02B000D539C39368C /* PasskeyManager.swift in Sources */,
|
||||||
|
E854B030B8ACED01056B39FD /* PrivateChatAppleApp.swift in Sources */,
|
||||||
|
FD0B1329E00BFC563CA92B34 /* SettingsStore.swift in Sources */,
|
||||||
|
3BEB977AA98055EF05F97989 /* SettingsView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
0356A63804EF725F92924B9B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
|
CODE_SIGNING_REQUIRED = NO;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = PrivateChat;
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_NAME = PrivateChatApple;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
2E89E0E6371B30AE59C3646B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.4;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.privatechat.apple;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
8269FBF8D3B1C19B7CA65298 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.4;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.privatechat.apple;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
9A5E4F1577926AC0869DBE31 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
|
CODE_SIGNING_REQUIRED = NO;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = PrivateChat;
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_NAME = PrivateChatApple;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
03EBB318C449F01FB0639745 /* Build configuration list for PBXNativeTarget "PrivateChatApple" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
9A5E4F1577926AC0869DBE31 /* Debug */,
|
||||||
|
0356A63804EF725F92924B9B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
A55C1DFA666ADF74C56D34DA /* Build configuration list for PBXProject "PrivateChatApple" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
8269FBF8D3B1C19B7CA65298 /* Debug */,
|
||||||
|
2E89E0E6371B30AE59C3646B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 12B40BE574717A46063F45C8 /* Project object */;
|
||||||
|
}
|
||||||
7
apple-client/PrivateChatApple.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
apple-client/PrivateChatApple.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>PrivateChatApple.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
133
apple-client/Sources/App/AppModels.swift
Normal file
133
apple-client/Sources/App/AppModels.swift
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct UserProfile: Codable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let displayName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthResponse: Codable {
|
||||||
|
let token: String
|
||||||
|
let user: UserProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccessKeySummary: Codable, Equatable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let credentialId: String
|
||||||
|
let label: String
|
||||||
|
let transports: [String]
|
||||||
|
let deviceType: String
|
||||||
|
let backedUp: Bool
|
||||||
|
let aaguid: String
|
||||||
|
let createdAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccessKeyListResponse: Codable {
|
||||||
|
let credentials: [AccessKeySummary]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionResponse: Codable {
|
||||||
|
let user: UserProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RegistrationOptionsResponse: Codable {
|
||||||
|
struct RelyingParty: Codable {
|
||||||
|
let name: String
|
||||||
|
let id: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserEntity: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let displayName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let expectedOrigin: String?
|
||||||
|
let rp: RelyingParty
|
||||||
|
let user: UserEntity
|
||||||
|
let challenge: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthenticationOptionsResponse: Codable {
|
||||||
|
let attemptId: String
|
||||||
|
let expectedOrigin: String?
|
||||||
|
let challenge: String
|
||||||
|
let rpId: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APIErrorResponse: Codable, Error {
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PasskeyRegistrationPayload: Encodable {
|
||||||
|
struct Response: Encodable {
|
||||||
|
let clientDataJSON: String
|
||||||
|
let attestationObject: String
|
||||||
|
let transports: [String]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: String
|
||||||
|
let rawId: String
|
||||||
|
let response: Response
|
||||||
|
let clientExtensionResults: [String: String]
|
||||||
|
let type: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PasskeyAuthenticationPayload: Encodable {
|
||||||
|
struct Response: Encodable {
|
||||||
|
let clientDataJSON: String
|
||||||
|
let authenticatorData: String
|
||||||
|
let signature: String
|
||||||
|
let userHandle: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: String
|
||||||
|
let rawId: String
|
||||||
|
let response: Response
|
||||||
|
let clientExtensionResults: [String: String]
|
||||||
|
let type: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
init?(base64URLEncoded value: String) {
|
||||||
|
var normalized = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
let padding = (4 - normalized.count % 4) % 4
|
||||||
|
normalized.append(String(repeating: "=", count: padding))
|
||||||
|
self.init(base64Encoded: normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64URLEncodedString() -> String {
|
||||||
|
base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Encodable {
|
||||||
|
func jsonString() -> String {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||||
|
|
||||||
|
guard let data = try? encoder.encode(AnyEncodable(self)),
|
||||||
|
let string = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AnyEncodable: Encodable {
|
||||||
|
private let encodeValue: (Encoder) throws -> Void
|
||||||
|
|
||||||
|
init(_ value: some Encodable) {
|
||||||
|
encodeValue = value.encode
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
try encodeValue(encoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
150
apple-client/Sources/App/BackendClient.swift
Normal file
150
apple-client/Sources/App/BackendClient.swift
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BackendClient {
|
||||||
|
let baseURL: URL
|
||||||
|
|
||||||
|
func register(username: String, password: String, displayName: String) async throws -> AuthResponse {
|
||||||
|
try await post(
|
||||||
|
path: "/api/auth/register",
|
||||||
|
body: RegisterRequest(
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
displayName: displayName.isEmpty ? nil : displayName
|
||||||
|
),
|
||||||
|
token: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(username: String, password: String) async throws -> AuthResponse {
|
||||||
|
try await post(
|
||||||
|
path: "/api/auth/login",
|
||||||
|
body: LoginRequest(username: username, password: password),
|
||||||
|
token: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSession(token: String) async throws -> SessionResponse {
|
||||||
|
try await get(path: "/api/auth/session", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout(token: String) async throws {
|
||||||
|
let _: EmptyResponse = try await post(path: "/api/auth/logout", body: EmptyBody(), token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAccessKeys(token: String) async throws -> [AccessKeySummary] {
|
||||||
|
let response: AccessKeyListResponse = try await get(path: "/api/webauthn/credentials", token: token)
|
||||||
|
return response.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAccessKeyRegistration(label: String?, token: String) async throws -> RegistrationOptionsResponse {
|
||||||
|
try await post(
|
||||||
|
path: "/api/webauthn/register/options",
|
||||||
|
body: RegisterAccessKeyRequest(label: label?.isEmpty == false ? label : nil),
|
||||||
|
token: token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishAccessKeyRegistration(payload: PasskeyRegistrationPayload, token: String) async throws {
|
||||||
|
let _: EmptyResponse = try await post(
|
||||||
|
path: "/api/webauthn/register/verify",
|
||||||
|
body: VerifyRegistrationRequest(credential: payload),
|
||||||
|
token: token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAccessKeyAuthentication() async throws -> AuthenticationOptionsResponse {
|
||||||
|
try await post(path: "/api/webauthn/authenticate/options", body: EmptyBody(), token: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishAccessKeyAuthentication(
|
||||||
|
attemptId: String,
|
||||||
|
payload: PasskeyAuthenticationPayload
|
||||||
|
) async throws -> AuthResponse {
|
||||||
|
try await post(
|
||||||
|
path: "/api/webauthn/authenticate/verify",
|
||||||
|
body: VerifyAuthenticationRequest(attemptId: attemptId, credential: payload),
|
||||||
|
token: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func get<Response: Decodable>(path: String, token: String?) async throws -> Response {
|
||||||
|
var request = URLRequest(url: baseURL.appending(path: path))
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if let token {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await perform(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post<Body: Encodable, Response: Decodable>(
|
||||||
|
path: String,
|
||||||
|
body: Body,
|
||||||
|
token: String?
|
||||||
|
) async throws -> Response {
|
||||||
|
var request = URLRequest(url: baseURL.appending(path: path))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
if let token {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
return try await perform(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform<Response: Decodable>(_ request: URLRequest) async throws -> Response {
|
||||||
|
let (data, rawResponse) = try await URLSession.shared.data(for: request)
|
||||||
|
let response = rawResponse as? HTTPURLResponse
|
||||||
|
|
||||||
|
guard let response else {
|
||||||
|
throw APIErrorResponse(message: "The backend did not return a valid HTTP response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
if (200 ..< 300).contains(response.statusCode) {
|
||||||
|
if Response.self == EmptyResponse.self {
|
||||||
|
return EmptyResponse() as! Response
|
||||||
|
}
|
||||||
|
|
||||||
|
return try decoder.decode(Response.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let apiError = try? decoder.decode(APIErrorResponse.self, from: data) {
|
||||||
|
throw apiError
|
||||||
|
}
|
||||||
|
|
||||||
|
throw APIErrorResponse(message: "The backend returned HTTP \(response.statusCode).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptyBody: Encodable {}
|
||||||
|
private struct EmptyResponse: Decodable {}
|
||||||
|
private struct RegisterRequest: Encodable {
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
let displayName: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LoginRequest: Encodable {
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RegisterAccessKeyRequest: Encodable {
|
||||||
|
let label: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VerifyRegistrationRequest: Encodable {
|
||||||
|
let credential: PasskeyRegistrationPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VerifyAuthenticationRequest: Encodable {
|
||||||
|
let attemptId: String
|
||||||
|
let credential: PasskeyAuthenticationPayload
|
||||||
|
}
|
||||||
57
apple-client/Sources/App/ContentView.swift
Normal file
57
apple-client/Sources/App/ContentView.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@Bindable var settings: SettingsStore
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
@State private var showSettings = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
EmbeddedWebAppView(settings: settings)
|
||||||
|
.toolbar {
|
||||||
|
#if os(macOS)
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
SettingsLink {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showSettings = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let user = settings.currentUser {
|
||||||
|
Text("Signed in as \(user.displayName)")
|
||||||
|
.font(.footnote)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if !os(macOS)
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
NavigationStack {
|
||||||
|
SettingsView(settings: settings)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
showSettings = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
112
apple-client/Sources/App/EmbeddedWebAppView.swift
Normal file
112
apple-client/Sources/App/EmbeddedWebAppView.swift
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct EmbeddedWebAppView: View {
|
||||||
|
@Bindable var settings: SettingsStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
PlatformWebView(script: settings.injectionScript, reloadToken: settings.webStateVersion)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
struct PlatformWebView: NSViewRepresentable {
|
||||||
|
let script: String
|
||||||
|
let reloadToken: UUID
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
|
context.coordinator.makeWebView(script: script)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||||
|
context.coordinator.update(webView: webView, script: script, reloadToken: reloadToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
struct PlatformWebView: UIViewRepresentable {
|
||||||
|
let script: String
|
||||||
|
let reloadToken: UUID
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
|
context.coordinator.makeWebView(script: script)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
|
context.coordinator.update(webView: webView, script: script, reloadToken: reloadToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
|
private var lastReloadToken: UUID?
|
||||||
|
private var pendingRefreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
func makeWebView(script: String) -> WKWebView {
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
let contentController = WKUserContentController()
|
||||||
|
contentController.addUserScript(
|
||||||
|
WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||||
|
)
|
||||||
|
configuration.userContentController = contentController
|
||||||
|
|
||||||
|
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
#if os(macOS)
|
||||||
|
webView.setValue(false, forKey: "drawsBackground")
|
||||||
|
#endif
|
||||||
|
loadIndex(into: webView)
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(webView: WKWebView, script: String, reloadToken: UUID) {
|
||||||
|
guard lastReloadToken != reloadToken else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReloadToken = reloadToken
|
||||||
|
pendingRefreshTask?.cancel()
|
||||||
|
pendingRefreshTask = Task { @MainActor [weak webView] in
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
guard !Task.isCancelled, let webView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.evaluateJavaScript(script) { _, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
webView.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIndex(into webView: WKWebView) {
|
||||||
|
let indexURL =
|
||||||
|
Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "WebApp/browser")
|
||||||
|
?? Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "WebApp")
|
||||||
|
|
||||||
|
guard let indexURL else {
|
||||||
|
let fallbackHTML = """
|
||||||
|
<html>
|
||||||
|
<body style="font-family:-apple-system;padding:32px;background:#08111d;color:#eff3ff">
|
||||||
|
<h2>Embedded chat bundle not found</h2>
|
||||||
|
<p>The Apple app expects the Angular client to be built into the bundled <code>WebApp</code> folder.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
webView.loadHTMLString(fallbackHTML, baseURL: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.loadFileURL(indexURL, allowingReadAccessTo: indexURL.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apple-client/Sources/App/PasskeyManager.swift
Normal file
126
apple-client/Sources/App/PasskeyManager.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import AuthenticationServices
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#else
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PasskeyManager: NSObject {
|
||||||
|
private var continuation: CheckedContinuation<ASAuthorization, Error>?
|
||||||
|
|
||||||
|
func register(options: RegistrationOptionsResponse) async throws -> PasskeyRegistrationPayload {
|
||||||
|
guard let challengeData = Data(base64URLEncoded: options.challenge),
|
||||||
|
let userData = Data(base64URLEncoded: options.user.id)
|
||||||
|
else {
|
||||||
|
throw APIErrorResponse(message: "The backend returned malformed access key registration options.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id)
|
||||||
|
let clientData = ASPublicKeyCredentialClientData(
|
||||||
|
challenge: challengeData,
|
||||||
|
origin: options.expectedOrigin ?? "http://localhost:4200"
|
||||||
|
)
|
||||||
|
let request = provider.createCredentialRegistrationRequest(
|
||||||
|
clientData: clientData,
|
||||||
|
name: options.user.name,
|
||||||
|
userID: userData
|
||||||
|
)
|
||||||
|
|
||||||
|
let authorization = try await perform(requests: [request])
|
||||||
|
|
||||||
|
guard let registration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else {
|
||||||
|
throw APIErrorResponse(message: "The platform did not return a valid access key registration.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return PasskeyRegistrationPayload(
|
||||||
|
id: registration.credentialID.base64URLEncodedString(),
|
||||||
|
rawId: registration.credentialID.base64URLEncodedString(),
|
||||||
|
response: .init(
|
||||||
|
clientDataJSON: registration.rawClientDataJSON.base64URLEncodedString(),
|
||||||
|
attestationObject: registration.rawAttestationObject?.base64URLEncodedString() ?? "",
|
||||||
|
transports: nil
|
||||||
|
),
|
||||||
|
clientExtensionResults: [:],
|
||||||
|
type: "public-key"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticate(options: AuthenticationOptionsResponse) async throws -> PasskeyAuthenticationPayload {
|
||||||
|
guard let challengeData = Data(base64URLEncoded: options.challenge) else {
|
||||||
|
throw APIErrorResponse(message: "The backend returned malformed access key sign-in options.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let rpId = options.rpId
|
||||||
|
?? URL(string: options.expectedOrigin ?? "")?.host
|
||||||
|
?? "localhost"
|
||||||
|
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
|
||||||
|
let clientData = ASPublicKeyCredentialClientData(
|
||||||
|
challenge: challengeData,
|
||||||
|
origin: options.expectedOrigin ?? "http://localhost:4200"
|
||||||
|
)
|
||||||
|
let request = provider.createCredentialAssertionRequest(clientData: clientData)
|
||||||
|
let authorization = try await perform(requests: [request])
|
||||||
|
|
||||||
|
guard let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {
|
||||||
|
throw APIErrorResponse(message: "The platform did not return a valid access key assertion.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return PasskeyAuthenticationPayload(
|
||||||
|
id: assertion.credentialID.base64URLEncodedString(),
|
||||||
|
rawId: assertion.credentialID.base64URLEncodedString(),
|
||||||
|
response: .init(
|
||||||
|
clientDataJSON: assertion.rawClientDataJSON.base64URLEncodedString(),
|
||||||
|
authenticatorData: assertion.rawAuthenticatorData.base64URLEncodedString(),
|
||||||
|
signature: assertion.signature.base64URLEncodedString(),
|
||||||
|
userHandle: assertion.userID.base64URLEncodedString()
|
||||||
|
),
|
||||||
|
clientExtensionResults: [:],
|
||||||
|
type: "public-key"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform(requests: [ASAuthorizationRequest]) async throws -> ASAuthorization {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.continuation = continuation
|
||||||
|
let controller = ASAuthorizationController(authorizationRequests: requests)
|
||||||
|
controller.delegate = self
|
||||||
|
controller.presentationContextProvider = self
|
||||||
|
controller.performRequests()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PasskeyManager: ASAuthorizationControllerDelegate {
|
||||||
|
func authorizationController(
|
||||||
|
controller _: ASAuthorizationController,
|
||||||
|
didCompleteWithAuthorization authorization: ASAuthorization
|
||||||
|
) {
|
||||||
|
continuation?.resume(returning: authorization)
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationController(
|
||||||
|
controller _: ASAuthorizationController,
|
||||||
|
didCompleteWithError error: Error
|
||||||
|
) {
|
||||||
|
continuation?.resume(throwing: error)
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PasskeyManager: ASAuthorizationControllerPresentationContextProviding {
|
||||||
|
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
||||||
|
#if os(macOS)
|
||||||
|
return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor()
|
||||||
|
#else
|
||||||
|
return UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.flatMap(\.windows)
|
||||||
|
.first(where: \.isKeyWindow) ?? ASPresentationAnchor()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
18
apple-client/Sources/App/PrivateChatAppleApp.swift
Normal file
18
apple-client/Sources/App/PrivateChatAppleApp.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PrivateChatAppleApp: App {
|
||||||
|
@State private var settings = SettingsStore()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView(settings: settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
Settings {
|
||||||
|
SettingsView(settings: settings)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
227
apple-client/Sources/App/SettingsStore.swift
Normal file
227
apple-client/Sources/App/SettingsStore.swift
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SettingsStore {
|
||||||
|
enum AuthMode: String, CaseIterable, Identifiable {
|
||||||
|
case login
|
||||||
|
case register
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var backendURLString: String
|
||||||
|
var currentUser: UserProfile?
|
||||||
|
var accessKeys: [AccessKeySummary]
|
||||||
|
var authMode: AuthMode = .login
|
||||||
|
var username = ""
|
||||||
|
var password = ""
|
||||||
|
var displayName = ""
|
||||||
|
var accessKeyLabel = ""
|
||||||
|
var infoMessage: String?
|
||||||
|
var errorMessage: String?
|
||||||
|
var isBusy = false
|
||||||
|
private(set) var webStateVersion = UUID()
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private let passkeyManager = PasskeyManager()
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let backendURL = "privatechat.apple.backendURL"
|
||||||
|
static let token = "privatechat.apple.token"
|
||||||
|
static let user = "privatechat.apple.user"
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
backendURLString = defaults.string(forKey: Keys.backendURL) ?? "http://localhost:3000"
|
||||||
|
accessKeys = []
|
||||||
|
|
||||||
|
if let data = defaults.data(forKey: Keys.user),
|
||||||
|
let user = try? JSONDecoder().decode(UserProfile.self, from: data)
|
||||||
|
{
|
||||||
|
currentUser = user
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await restoreSessionIfPossible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var token: String? {
|
||||||
|
defaults.string(forKey: Keys.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAuthenticated: Bool {
|
||||||
|
token != nil && currentUser != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var injectionScript: String {
|
||||||
|
let serverValue = backendURLString.jsonString()
|
||||||
|
let tokenValue = (token ?? "").jsonString()
|
||||||
|
let userValue: String
|
||||||
|
|
||||||
|
if let currentUser {
|
||||||
|
userValue = currentUser.jsonString()
|
||||||
|
} else {
|
||||||
|
userValue = "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('privatechat.embeddedMode', '1');
|
||||||
|
localStorage.setItem('privatechat.serverUrl', \(serverValue));
|
||||||
|
if (\(tokenValue) && \(tokenValue) !== '""') {
|
||||||
|
localStorage.setItem('privatechat.token', \(tokenValue));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('privatechat.token');
|
||||||
|
}
|
||||||
|
if (\(userValue) !== null) {
|
||||||
|
localStorage.setItem('privatechat.user', JSON.stringify(\(userValue)));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('privatechat.user');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync native settings into localStorage', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBackendURL() {
|
||||||
|
defaults.set(backendURLString, forKey: Keys.backendURL)
|
||||||
|
invalidateWebState(info: "Backend URL updated.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticate() async {
|
||||||
|
await runTask { [self] in
|
||||||
|
switch authMode {
|
||||||
|
case .login:
|
||||||
|
let response = try await self.apiClient().login(username: self.username, password: self.password)
|
||||||
|
try await self.applyAuthResponse(response, success: "Signed in as \(response.user.displayName).")
|
||||||
|
case .register:
|
||||||
|
let response = try await self.apiClient().register(
|
||||||
|
username: self.username,
|
||||||
|
password: self.password,
|
||||||
|
displayName: self.displayName
|
||||||
|
)
|
||||||
|
try await self.applyAuthResponse(response, success: "Account created for \(response.user.displayName).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signInWithAccessKey() async {
|
||||||
|
await runTask { [self] in
|
||||||
|
let options = try await self.apiClient().startAccessKeyAuthentication()
|
||||||
|
let credential = try await self.passkeyManager.authenticate(options: options)
|
||||||
|
let response = try await self.apiClient().finishAccessKeyAuthentication(
|
||||||
|
attemptId: options.attemptId,
|
||||||
|
payload: credential
|
||||||
|
)
|
||||||
|
try await self.applyAuthResponse(response, success: "Signed in as \(response.user.displayName).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAccessKey() async {
|
||||||
|
guard let token else {
|
||||||
|
errorMessage = "Sign in before registering an access key."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await runTask { [self] in
|
||||||
|
let options = try await self.apiClient().startAccessKeyRegistration(
|
||||||
|
label: self.accessKeyLabel.isEmpty ? nil : self.accessKeyLabel,
|
||||||
|
token: token
|
||||||
|
)
|
||||||
|
let payload = try await self.passkeyManager.register(options: options)
|
||||||
|
try await self.apiClient().finishAccessKeyRegistration(payload: payload, token: token)
|
||||||
|
self.accessKeyLabel = ""
|
||||||
|
self.accessKeys = try await self.apiClient().listAccessKeys(token: token)
|
||||||
|
self.infoMessage = "Access key registered."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() async {
|
||||||
|
guard let token else {
|
||||||
|
clearAuthState(info: "Signed out.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await runTask { [self] in
|
||||||
|
try? await self.apiClient().logout(token: token)
|
||||||
|
self.clearAuthState(info: "Signed out.")
|
||||||
|
self.authMode = .login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSessionIfPossible() async {
|
||||||
|
guard let token else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await runTask(clearMessages: false) { [self] in
|
||||||
|
let response = try await self.apiClient().restoreSession(token: token)
|
||||||
|
self.currentUser = response.user
|
||||||
|
self.accessKeys = try await self.apiClient().listAccessKeys(token: token)
|
||||||
|
self.infoMessage = "Restored session for \(response.user.displayName)."
|
||||||
|
self.invalidateWebState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apiClient() throws -> BackendClient {
|
||||||
|
guard let url = URL(string: backendURLString.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
|
url.scheme?.hasPrefix("http") == true
|
||||||
|
else {
|
||||||
|
throw APIErrorResponse(message: "Enter a valid backend URL before continuing.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackendClient(baseURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAuthResponse(_ response: AuthResponse, success: String) async throws {
|
||||||
|
currentUser = response.user
|
||||||
|
defaults.set(response.token, forKey: Keys.token)
|
||||||
|
defaults.set(try JSONEncoder().encode(response.user), forKey: Keys.user)
|
||||||
|
accessKeys = try await apiClient().listAccessKeys(token: response.token)
|
||||||
|
password = ""
|
||||||
|
infoMessage = success
|
||||||
|
invalidateWebState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearAuthState(info: String) {
|
||||||
|
currentUser = nil
|
||||||
|
accessKeys = []
|
||||||
|
defaults.removeObject(forKey: Keys.token)
|
||||||
|
defaults.removeObject(forKey: Keys.user)
|
||||||
|
infoMessage = info
|
||||||
|
errorMessage = nil
|
||||||
|
invalidateWebState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invalidateWebState(info: String? = nil) {
|
||||||
|
if let info {
|
||||||
|
infoMessage = info
|
||||||
|
}
|
||||||
|
|
||||||
|
webStateVersion = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runTask(clearMessages: Bool = true, operation: @escaping () async throws -> Void) async {
|
||||||
|
if clearMessages {
|
||||||
|
errorMessage = nil
|
||||||
|
infoMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isBusy = true
|
||||||
|
defer { isBusy = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await operation()
|
||||||
|
} catch let apiError as APIErrorResponse {
|
||||||
|
errorMessage = apiError.message
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
apple-client/Sources/App/SettingsView.swift
Normal file
127
apple-client/Sources/App/SettingsView.swift
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Bindable var settings: SettingsStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Backend") {
|
||||||
|
TextField("Backend URL", text: $settings.backendURLString)
|
||||||
|
.onSubmit {
|
||||||
|
settings.saveBackendURL()
|
||||||
|
}
|
||||||
|
.platformCredentialField()
|
||||||
|
|
||||||
|
Button("Apply Backend URL") {
|
||||||
|
settings.saveBackendURL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Session") {
|
||||||
|
if let user = settings.currentUser {
|
||||||
|
LabeledContent("Signed in as", value: user.displayName)
|
||||||
|
LabeledContent("Username", value: user.username)
|
||||||
|
|
||||||
|
Button("Sign out", role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await settings.signOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Picker("Mode", selection: $settings.authMode) {
|
||||||
|
ForEach(SettingsStore.AuthMode.allCases) { mode in
|
||||||
|
Text(mode == .login ? "Log in" : "Register").tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
if settings.authMode == .register {
|
||||||
|
TextField("Display name", text: $settings.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Username", text: $settings.username)
|
||||||
|
.platformCredentialField()
|
||||||
|
|
||||||
|
SecureField("Password", text: $settings.password)
|
||||||
|
|
||||||
|
Button(settings.authMode == .login ? "Authenticate" : "Create account") {
|
||||||
|
Task {
|
||||||
|
await settings.authenticate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(settings.isBusy)
|
||||||
|
|
||||||
|
Button("Use access key") {
|
||||||
|
Task {
|
||||||
|
await settings.signInWithAccessKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(settings.isBusy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Access Keys") {
|
||||||
|
if settings.currentUser == nil {
|
||||||
|
Text("Sign in before registering or listing access keys.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
TextField("New access key label", text: $settings.accessKeyLabel)
|
||||||
|
Button("Register access key") {
|
||||||
|
Task {
|
||||||
|
await settings.registerAccessKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(settings.isBusy)
|
||||||
|
|
||||||
|
if settings.accessKeys.isEmpty {
|
||||||
|
Text("No access keys registered yet.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(settings.accessKeys) { key in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(key.label)
|
||||||
|
.font(.headline)
|
||||||
|
Text("Device: \(key.deviceType)\(key.backedUp ? " / backed up" : "")")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Transports: \(key.transports.isEmpty ? "unspecified" : key.transports.joined(separator: ", "))")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let infoMessage = settings.infoMessage {
|
||||||
|
Section("Status") {
|
||||||
|
Text(infoMessage)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage = settings.errorMessage {
|
||||||
|
Section("Error") {
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.padding()
|
||||||
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func platformCredentialField() -> some View {
|
||||||
|
#if os(iOS)
|
||||||
|
textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
#else
|
||||||
|
self
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
355
apple-client/WebApp/3rdpartylicenses.txt
Normal file
355
apple-client/WebApp/3rdpartylicenses.txt
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: @angular/core
|
||||||
|
License: "MIT"
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: rxjs
|
||||||
|
License: "Apache-2.0"
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: tslib
|
||||||
|
License: "0BSD"
|
||||||
|
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: @angular/common
|
||||||
|
License: "MIT"
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: @angular/platform-browser
|
||||||
|
License: "MIT"
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: @angular/router
|
||||||
|
License: "MIT"
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: @angular/forms
|
||||||
|
License: "MIT"
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
BIN
apple-client/WebApp/browser/favicon.ico
Normal file
BIN
apple-client/WebApp/browser/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
16
apple-client/WebApp/browser/index.html
Normal file
16
apple-client/WebApp/browser/index.html
Normal file
File diff suppressed because one or more lines are too long
8
apple-client/WebApp/browser/main-YTU56RI2.js
Normal file
8
apple-client/WebApp/browser/main-YTU56RI2.js
Normal file
File diff suppressed because one or more lines are too long
1
apple-client/WebApp/browser/styles-YLPXNZVT.css
Normal file
1
apple-client/WebApp/browser/styles-YLPXNZVT.css
Normal file
File diff suppressed because one or more lines are too long
3
apple-client/WebApp/prerendered-routes.json
Normal file
3
apple-client/WebApp/prerendered-routes.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"routes": {}
|
||||||
|
}
|
||||||
44
apple-client/project.yml
Normal file
44
apple-client/project.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: PrivateChatApple
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.privatechat
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: 6.0
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.privatechat.apple
|
||||||
|
MARKETING_VERSION: 1.0
|
||||||
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET: 17.4
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: 14.4
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
targets:
|
||||||
|
PrivateChatApple:
|
||||||
|
type: application
|
||||||
|
supportedDestinations:
|
||||||
|
- iOS
|
||||||
|
- macOS
|
||||||
|
sources:
|
||||||
|
- path: Sources/App
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_NAME: PrivateChatApple
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName: PrivateChat
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.social-networking
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
|
CODE_SIGNING_ALLOWED: NO
|
||||||
|
CODE_SIGNING_REQUIRED: NO
|
||||||
|
preBuildScripts:
|
||||||
|
- name: Build Embedded Angular Client
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$SRCROOT/.."
|
||||||
|
mkdir -p "$SRCROOT/WebApp"
|
||||||
|
npm run build --prefix client -- --base-href ./ --output-path "$SRCROOT/WebApp"
|
||||||
|
postBuildScripts:
|
||||||
|
- name: Copy Embedded Angular Client
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
DESTINATION="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/WebApp"
|
||||||
|
rm -rf "$DESTINATION"
|
||||||
|
mkdir -p "$DESTINATION"
|
||||||
|
cp -R "$SRCROOT/WebApp/." "$DESTINATION"
|
||||||
17
client/.editorconfig
Normal file
17
client/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
12
client/.prettierrc
Normal file
12
client/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
client/.vscode/extensions.json
vendored
Normal file
4
client/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
20
client/.vscode/launch.json
vendored
Normal file
20
client/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
client/.vscode/mcp.json
vendored
Normal file
9
client/.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://angular.dev/ai/mcp
|
||||||
|
"servers": {
|
||||||
|
"angular-cli": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@angular/cli", "mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
client/.vscode/tasks.json
vendored
Normal file
42
client/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
59
client/README.md
Normal file
59
client/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Client
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
97
client/angular.json
Normal file
97
client/angular.json
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"client": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss",
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:class": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:directive": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:guard": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:interceptor": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:pipe": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:resolver": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:service": {
|
||||||
|
"skipTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "700kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "client:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "client:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7784
client/package-lock.json
generated
Normal file
7784
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
client/package.json
Normal file
33
client/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"prepare-env": "node scripts/write-env.js",
|
||||||
|
"ng": "node node_modules/@angular/cli/bin/ng.js",
|
||||||
|
"start": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js serve",
|
||||||
|
"build": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js build",
|
||||||
|
"watch": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js build --watch --configuration development",
|
||||||
|
"test": "node node_modules/@angular/cli/bin/ng.js test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@11.10.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^21.2.0",
|
||||||
|
"@angular/compiler": "^21.2.0",
|
||||||
|
"@angular/core": "^21.2.0",
|
||||||
|
"@angular/forms": "^21.2.0",
|
||||||
|
"@angular/platform-browser": "^21.2.0",
|
||||||
|
"@angular/router": "^21.2.0",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^21.2.1",
|
||||||
|
"@angular/cli": "^21.2.1",
|
||||||
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
client/public/env.js
Normal file
3
client/public/env.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
window.__PRIVATECHAT_ENV__ = {
|
||||||
|
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
|
||||||
|
};
|
||||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
17
client/scripts/write-env.js
Normal file
17
client/scripts/write-env.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
const rootEnvPath = path.resolve(__dirname, '../../.env');
|
||||||
|
const outputPath = path.resolve(__dirname, '../public/env.js');
|
||||||
|
|
||||||
|
dotenv.config({ path: rootEnvPath });
|
||||||
|
|
||||||
|
const runtimeEnv = {
|
||||||
|
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outputPath, fileContents, 'utf8');
|
||||||
13
client/src/app/app.config.ts
Normal file
13
client/src/app/app.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideHttpClient(withFetch()),
|
||||||
|
provideRouter(routes)
|
||||||
|
]
|
||||||
|
};
|
||||||
1
client/src/app/app.html
Normal file
1
client/src/app/app.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet />
|
||||||
24
client/src/app/app.routes.ts
Normal file
24
client/src/app/app.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { ApprovalPageComponent } from './approval-page.component';
|
||||||
|
import { ChatPageComponent } from './chat-page.component';
|
||||||
|
import { HomePageComponent } from './home-page.component';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: HomePageComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'chat/:peerId',
|
||||||
|
component: ChatPageComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'approvals',
|
||||||
|
component: ApprovalPageComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
4
client/src/app/app.scss
Normal file
4
client/src/app/app.scss
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
14
client/src/app/app.ts
Normal file
14
client/src/app/app.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.scss',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
constructor(_: ThemeService) {}
|
||||||
|
}
|
||||||
45
client/src/app/approval-page.component.html
Normal file
45
client/src/app/approval-page.component.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<main class="approval-shell py-4">
|
||||||
|
<div class="container-lg">
|
||||||
|
<section class="panel p-4 p-lg-5">
|
||||||
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
||||||
|
<h1 class="h3 mt-2 mb-1">Account approvals</h1>
|
||||||
|
<p class="text-secondary mb-0">Only <code>ladparis</code> can activate newly registered accounts.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge rounded-pill text-bg-dark">{{ pendingUsers().length }} pending</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<div class="alert alert-danger mb-4">{{ errorMessage() }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="text-secondary">Loading pending accounts...</div>
|
||||||
|
} @else if (pendingUsers().length === 0) {
|
||||||
|
<div class="empty-state p-4 text-center text-secondary">No accounts are waiting for approval.</div>
|
||||||
|
} @else {
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
@for (user of pendingUsers(); track user.id) {
|
||||||
|
<article class="approval-card d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 p-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ user.displayName }}</div>
|
||||||
|
<div class="text-secondary">{{ user.username }}</div>
|
||||||
|
<div class="small text-secondary">Registered {{ user.createdAt | date: 'medium' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-accent"
|
||||||
|
type="button"
|
||||||
|
[disabled]="approvingUserId() === user.id"
|
||||||
|
(click)="approve(user.id)"
|
||||||
|
>
|
||||||
|
{{ approvingUserId() === user.id ? 'Approving...' : 'Approve account' }}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
5
client/src/app/approval-page.component.scss
Normal file
5
client/src/app/approval-page.component.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.approval-card {
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
}
|
||||||
61
client/src/app/approval-page.component.ts
Normal file
61
client/src/app/approval-page.component.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import type { PendingApprovalUser } from './models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-approval-page',
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
templateUrl: './approval-page.component.html',
|
||||||
|
styleUrl: './approval-page.component.scss',
|
||||||
|
})
|
||||||
|
export class ApprovalPageComponent {
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly pendingUsers = signal<PendingApprovalUser[]>([]);
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly errorMessage = signal<string | null>(null);
|
||||||
|
readonly approvingUserId = signal<string | null>(null);
|
||||||
|
|
||||||
|
constructor(readonly session: ChatSessionService) {
|
||||||
|
if (!this.session.isApprovalAdmin()) {
|
||||||
|
void this.router.navigateByUrl('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.loadPendingUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(userId: string): Promise<void> {
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.approvingUserId.set(userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.session.approvePendingUser(userId);
|
||||||
|
this.pendingUsers.update((users) => users.filter((user) => user.id !== userId));
|
||||||
|
} catch (error) {
|
||||||
|
this.errorMessage.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not approve that account.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.approvingUserId.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPendingUsers(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.pendingUsers.set(await this.session.loadPendingApprovalUsers());
|
||||||
|
} catch (error) {
|
||||||
|
this.errorMessage.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not load pending account approvals.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
client/src/app/chat-page.component.html
Normal file
199
client/src/app/chat-page.component.html
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<main class="chat-shell py-4">
|
||||||
|
<div class="container-lg">
|
||||||
|
<section class="chat-page panel p-3 p-lg-4">
|
||||||
|
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
||||||
|
@if (currentUser(); as connectedUser) {
|
||||||
|
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
|
||||||
|
<div class="status-indicators mt-2">
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
||||||
|
<span>Signaling</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
|
||||||
|
<span>WebRTC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
|
||||||
|
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (peer(); as selectedPeer) {
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-light"
|
||||||
|
type="button"
|
||||||
|
[disabled]="selectedPeer.channelState === 'open'"
|
||||||
|
(click)="ensureConnection()"
|
||||||
|
>
|
||||||
|
{{ selectedPeer.channelState === 'open' ? 'Connected' : 'Open channel' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-layout">
|
||||||
|
<aside class="peer-sidebar">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h5 mb-1">Connected peers</h2>
|
||||||
|
<p class="small text-secondary mb-0">Switch between active direct chats.</p>
|
||||||
|
</div>
|
||||||
|
<span class="peer-count">{{ session.peers().length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="peer-list">
|
||||||
|
@if (session.peers().length === 0) {
|
||||||
|
<div class="empty-chat empty-peers">
|
||||||
|
No peers are currently connected.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||||
|
<button
|
||||||
|
class="peer-tile text-start"
|
||||||
|
type="button"
|
||||||
|
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
||||||
|
(click)="switchPeer(connectedPeer.id)"
|
||||||
|
>
|
||||||
|
<div class="peer-tile-row">
|
||||||
|
<span class="peer-tile-title">
|
||||||
|
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
|
||||||
|
@if (isPeerTyping(connectedPeer.id)) {
|
||||||
|
<span class="peer-typing-dots" aria-label="Typing">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="status-led peer-tile-status"
|
||||||
|
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
|
||||||
|
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
|
||||||
|
[attr.aria-label]="
|
||||||
|
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
|
||||||
|
? 'Connected'
|
||||||
|
: 'Disconnected'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="chat-main">
|
||||||
|
<div class="conversation">
|
||||||
|
@if (conversation().length === 0) {
|
||||||
|
<div class="empty-chat">
|
||||||
|
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (entry of conversation(); track entry.id) {
|
||||||
|
<article
|
||||||
|
class="bubble"
|
||||||
|
[class.bubble-incoming]="entry.direction === 'incoming'"
|
||||||
|
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||||
|
[class.bubble-system]="entry.direction === 'system'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="bubble-delete"
|
||||||
|
type="button"
|
||||||
|
(click)="deleteMessage(entry)"
|
||||||
|
title="Delete message"
|
||||||
|
aria-label="Delete message"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div class="bubble-meta">
|
||||||
|
<span>{{ entry.authorLabel }}</span>
|
||||||
|
<time>{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (entry.kind) {
|
||||||
|
@case ('text') {
|
||||||
|
<p class="mb-0">{{ entry.text }}</p>
|
||||||
|
}
|
||||||
|
@case ('json') {
|
||||||
|
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
|
||||||
|
}
|
||||||
|
@case ('file') {
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
@if (isImageEntry(entry)) {
|
||||||
|
<img
|
||||||
|
class="bubble-image"
|
||||||
|
[src]="entry.downloadUrl"
|
||||||
|
[alt]="entry.fileName || 'Shared image'"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||||
|
@if (entry.fileSize) {
|
||||||
|
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (entry.downloadUrl) {
|
||||||
|
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<p class="mb-0">{{ entry.text }}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
@if (peer(); as selectedPeer) {
|
||||||
|
<input
|
||||||
|
#fileInput
|
||||||
|
class="composer-file-input"
|
||||||
|
type="file"
|
||||||
|
[disabled]="selectedPeer.channelState !== 'open'"
|
||||||
|
(change)="sendFile(selectedPeer.id, fileInput)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="composer-plus"
|
||||||
|
type="button"
|
||||||
|
[disabled]="selectedPeer.channelState !== 'open'"
|
||||||
|
(click)="fileInput.click()"
|
||||||
|
title="Send file"
|
||||||
|
aria-label="Send file"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
class="form-control composer-textarea"
|
||||||
|
rows="3"
|
||||||
|
[(ngModel)]="messageText"
|
||||||
|
(ngModelChange)="handleMessageTextChange($event)"
|
||||||
|
(keydown.enter)="handleComposerEnter($event)"
|
||||||
|
[disabled]="!session.isSelectedPeerReady()"
|
||||||
|
placeholder="Write a text message to your peer"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="send-emoji"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!session.isSelectedPeerReady()"
|
||||||
|
(click)="sendMessage()"
|
||||||
|
title="Send message"
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
316
client/src/app/chat-page.component.scss
Normal file
316
client/src/app/chat-page.component.scss
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
color: var(--page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: 0 20px 60px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicators {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led {
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 0 1px var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-ok {
|
||||||
|
background: #59d66f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-connecting {
|
||||||
|
background: #f3ad3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-offline {
|
||||||
|
background: #eb5d64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-sidebar {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.3rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-count {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--badge-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: calc(100dvh - 17rem);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--surface-background);
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile:hover,
|
||||||
|
.peer-tile:focus-visible,
|
||||||
|
.peer-tile-active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-height: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots span {
|
||||||
|
width: 0.38rem;
|
||||||
|
height: 0.38rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--page-text);
|
||||||
|
opacity: 0.28;
|
||||||
|
animation: peer-typing-pulse 900ms infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots span:nth-child(2) {
|
||||||
|
animation-delay: 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots span:nth-child(3) {
|
||||||
|
animation-delay: 240ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-height: 24rem;
|
||||||
|
max-height: calc(100dvh - 20rem);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
position: relative;
|
||||||
|
max-width: min(75%, 34rem);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.45rem;
|
||||||
|
right: 0.55rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--danger-background);
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-incoming {
|
||||||
|
justify-self: start;
|
||||||
|
color: var(--incoming-bubble-text);
|
||||||
|
background: var(--incoming-bubble-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-outgoing {
|
||||||
|
justify-self: end;
|
||||||
|
color: var(--outgoing-bubble-text);
|
||||||
|
background: var(--outgoing-bubble-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-system {
|
||||||
|
justify-self: center;
|
||||||
|
max-width: 90%;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
background: var(--badge-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.9rem;
|
||||||
|
align-items: end;
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid var(--surface-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-plus,
|
||||||
|
.send-emoji {
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 3.25rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-textarea,
|
||||||
|
.composer-textarea:focus {
|
||||||
|
color: var(--page-text);
|
||||||
|
background-color: var(--input-background);
|
||||||
|
border-color: var(--input-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-textarea::placeholder {
|
||||||
|
color: var(--placeholder-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-plus {
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--badge-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-emoji {
|
||||||
|
background: linear-gradient(135deg, #def7dd, #9bd5ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-image {
|
||||||
|
width: 200px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-download {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-json {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-chat {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px dashed var(--input-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: var(--page-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-peers {
|
||||||
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h3,
|
||||||
|
.small {
|
||||||
|
color: var(--page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes peer-typing-pulse {
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.28;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.chat-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-list {
|
||||||
|
max-height: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 88%;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
client/src/app/chat-page.component.ts
Normal file
151
client/src/app/chat-page.component.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import type { ChatEntry, ConnectionState } from './models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chat-page',
|
||||||
|
imports: [CommonModule, FormsModule, RouterLink],
|
||||||
|
templateUrl: './chat-page.component.html',
|
||||||
|
styleUrl: './chat-page.component.scss',
|
||||||
|
})
|
||||||
|
export class ChatPageComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
||||||
|
initialValue: this.route.snapshot.paramMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
messageText = '';
|
||||||
|
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||||
|
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||||
|
readonly currentUser = computed(() => this.session.currentUser());
|
||||||
|
readonly conversation = computed(() =>
|
||||||
|
this.session
|
||||||
|
.messages()
|
||||||
|
.filter((entry) => entry.peerId === this.peerId()),
|
||||||
|
);
|
||||||
|
readonly webRtcState = computed<ConnectionState>(() => {
|
||||||
|
const selectedPeer = this.peer();
|
||||||
|
|
||||||
|
if (!selectedPeer) {
|
||||||
|
return 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected') {
|
||||||
|
return 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPeer.channelState === 'connecting' || selectedPeer.connectionState === 'connecting') {
|
||||||
|
return 'connecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'disconnected';
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(readonly session: ChatSessionService) {
|
||||||
|
if (!this.session.currentUser()) {
|
||||||
|
void this.router.navigateByUrl('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.selectPeer(peerId);
|
||||||
|
void this.session.connectToPeer(peerId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureConnection(): Promise<void> {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.selectPeer(peerId);
|
||||||
|
await this.session.connectToPeer(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(): Promise<void> {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.sendText(peerId, this.messageText);
|
||||||
|
this.messageText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleComposerEnter(event: Event): void {
|
||||||
|
if (!(event instanceof KeyboardEvent) || event.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
void this.sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessageTextChange(text: string): void {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.notifyTypingActivity(peerId, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
||||||
|
const file = input.files?.item(0);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.sendFile(peerId, file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMessage(entry: ChatEntry): Promise<void> {
|
||||||
|
await this.session.deleteMessage(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
isImageEntry(entry: ChatEntry): boolean {
|
||||||
|
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPeerTyping(peerId: string): boolean {
|
||||||
|
return this.session.typingPeerIds().includes(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
|
||||||
|
if (state === 'connected') {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'connecting') {
|
||||||
|
return 'connecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchPeer(peerId: string): Promise<void> {
|
||||||
|
if (!peerId || peerId === this.peerId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.selectPeer(peerId);
|
||||||
|
await this.router.navigate(['/chat', peerId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
1799
client/src/app/chat-session.service.ts
Normal file
1799
client/src/app/chat-session.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
269
client/src/app/home-page.component.html
Normal file
269
client/src/app/home-page.component.html
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<main class="shell py-4 py-lg-5">
|
||||||
|
<div class="container-xl">
|
||||||
|
@if (!embeddedMode) {
|
||||||
|
<section class="hero-panel mb-4 mb-lg-5 p-4 p-lg-5">
|
||||||
|
<div class="d-flex flex-column flex-lg-row align-items-start align-items-lg-center justify-content-between gap-4">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||||
|
<span class="eyebrow mb-0">WebRTC Private Chat</span>
|
||||||
|
<button
|
||||||
|
class="theme-toggle"
|
||||||
|
type="button"
|
||||||
|
(click)="cycleTheme()"
|
||||||
|
[attr.aria-label]="'Theme mode: ' + theme.mode() + '. Switch to ' + theme.nextMode()"
|
||||||
|
[title]="'Theme mode: ' + theme.mode() + '. Click for ' + theme.nextMode()"
|
||||||
|
>
|
||||||
|
<span class="theme-toggle-icon" aria-hidden="true">{{ theme.emoji() }}</span>
|
||||||
|
<span class="theme-toggle-label">{{ theme.mode() }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (session.currentUser(); as user) {
|
||||||
|
<div class="session-card p-3 p-lg-4">
|
||||||
|
<div class="text-uppercase small text-secondary mb-2">Signed in</div>
|
||||||
|
<div class="h4 mb-1">{{ user.displayName }}</div>
|
||||||
|
<div class="text-secondary mb-3">{{ user.username }}</div>
|
||||||
|
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
||||||
|
<button class="btn btn-accent w-100 mb-2" type="button" [disabled]="!canOpenChatUi()" (click)="openChatUi()">
|
||||||
|
Open chat UI
|
||||||
|
</button>
|
||||||
|
@if (session.isApprovalAdmin()) {
|
||||||
|
<a class="btn btn-outline-light w-100 mb-2" routerLink="/approvals">Approve accounts</a>
|
||||||
|
}
|
||||||
|
<button class="btn btn-outline-light w-100" type="button" (click)="logout()">Log out</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!session.currentUser()) {
|
||||||
|
@if (embeddedMode) {
|
||||||
|
<section class="panel p-4 p-lg-5 text-center">
|
||||||
|
<h2 class="h3 mb-3">Open Settings to sign in</h2>
|
||||||
|
<p class="text-secondary mb-0">
|
||||||
|
This embedded client expects authentication and backend configuration from the native app settings.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
} @else {
|
||||||
|
<section class="row g-4 align-items-stretch">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="panel p-4 h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
|
||||||
|
<p class="text-secondary mb-0">Use the Fastify server for authentication and peer discovery.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="serverUrl">Backend URL</label>
|
||||||
|
<input
|
||||||
|
id="serverUrl"
|
||||||
|
name="serverUrl"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
[(ngModel)]="serverUrl"
|
||||||
|
placeholder="http://localhost:3000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
[class.btn-primary]="authMode === 'login'"
|
||||||
|
[class.btn-outline-primary]="authMode !== 'login'"
|
||||||
|
type="button"
|
||||||
|
(click)="authMode = 'login'"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
[class.btn-primary]="authMode === 'register'"
|
||||||
|
[class.btn-outline-primary]="authMode !== 'register'"
|
||||||
|
type="button"
|
||||||
|
(click)="authMode = 'register'"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="d-grid gap-3" (ngSubmit)="submitAuth()">
|
||||||
|
@if (authMode === 'register') {
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="displayName">Display name</label>
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
[(ngModel)]="displayName"
|
||||||
|
placeholder="Operator One"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
[(ngModel)]="username"
|
||||||
|
placeholder="alice"
|
||||||
|
[attr.autocomplete]="authMode === 'login' ? 'username webauthn' : 'username'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-accent btn-lg mt-2" type="submit">
|
||||||
|
{{ authMode === 'login' ? 'Enter chat' : 'Create account' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (authMode === 'login' && session.webAuthnSupported()) {
|
||||||
|
<div class="text-center small text-secondary mt-1">or</div>
|
||||||
|
<button class="btn btn-outline-light btn-lg" type="button" (click)="loginWithAccessKey()">
|
||||||
|
🔑 Use access key
|
||||||
|
</button>
|
||||||
|
<div class="small text-secondary">
|
||||||
|
Leave the username blank to choose from discoverable passkeys, or enter it to target one account.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (session.error()) {
|
||||||
|
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (session.notice()) {
|
||||||
|
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="panel panel-muted p-4 h-100">
|
||||||
|
<h2 class="h3 mb-3">Transport model</h2>
|
||||||
|
<div class="info-rail d-grid gap-3">
|
||||||
|
<article>
|
||||||
|
<div class="small text-uppercase text-secondary mb-2">1. Authenticate</div>
|
||||||
|
<p class="mb-0">Register or log in against the Fastify API to receive a JWT.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<div class="small text-uppercase text-secondary mb-2">2. Discover peers</div>
|
||||||
|
<p class="mb-0">Open the WebSocket signaling session and receive the online peer list.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<div class="small text-uppercase text-secondary mb-2">3. Exchange data directly</div>
|
||||||
|
<p class="mb-0">Create a WebRTC data channel and send text, JSON, or files peer-to-peer.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<section class="row g-4 align-items-stretch">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="panel p-4 h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="h3 mb-1">Connection settings</h2>
|
||||||
|
<p class="text-secondary mb-0">Manage the backend endpoint used for auth and signaling.</p>
|
||||||
|
</div>
|
||||||
|
@if (session.isApprovalAdmin()) {
|
||||||
|
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!embeddedMode) {
|
||||||
|
<label class="form-label" for="connectedServerUrl">Backend URL</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input
|
||||||
|
id="connectedServerUrl"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="serverUrl"
|
||||||
|
(blur)="applyServerUrl()"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="applyServerUrl()">Apply</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state p-4 text-center text-secondary">
|
||||||
|
Backend settings are managed by the native app in embedded mode.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="small status-pill mt-3">{{ session.status() }}</div>
|
||||||
|
|
||||||
|
@if (session.error()) {
|
||||||
|
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (session.notice()) {
|
||||||
|
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="panel p-4 h-100">
|
||||||
|
<section class="access-key-panel">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Access keys</h3>
|
||||||
|
<p class="small text-secondary mb-0">Register one or more WebAuthn credentials for this account.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge rounded-pill text-bg-dark">{{ session.accessKeys().length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!embeddedMode && session.webAuthnSupported()) {
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="accessKeyLabel"
|
||||||
|
placeholder="Laptop passkey"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-outline-light" type="button" (click)="registerAccessKey()">Add key</button>
|
||||||
|
</div>
|
||||||
|
} @else if (!embeddedMode) {
|
||||||
|
<div class="alert alert-warning mb-3">This browser does not expose WebAuthn registration APIs.</div>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary mb-3">
|
||||||
|
Access keys are managed through the native app in embedded mode.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@if (session.accessKeys().length === 0) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary">No access keys registered yet.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (key of session.accessKeys(); track key.id) {
|
||||||
|
<article class="access-key-card p-3">
|
||||||
|
<div class="fw-semibold">{{ key.label }}</div>
|
||||||
|
<div class="small text-secondary">Device: {{ key.deviceType }}{{ key.backedUp ? ' / backed up' : '' }}</div>
|
||||||
|
<div class="small text-secondary">Transports: {{ key.transports.length > 0 ? key.transports.join(', ') : 'unspecified' }}</div>
|
||||||
|
<div class="small text-secondary">Added: {{ key.createdAt | date: 'medium' }}</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
156
client/src/app/home-page.component.scss
Normal file
156
client/src/app/home-page.component.scss
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
color: var(--page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel,
|
||||||
|
.panel,
|
||||||
|
.session-card,
|
||||||
|
.empty-state {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
background: var(--panel-background);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: 0 20px 60px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
border-radius: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-muted {
|
||||||
|
background: var(--panel-alt-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-color);
|
||||||
|
background: var(--accent-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-width: 7.5rem;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: capitalize;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle-label {
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover,
|
||||||
|
.theme-toggle:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
min-width: min(100%, 18rem);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--badge-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
color: #06111d;
|
||||||
|
border: 0;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover,
|
||||||
|
.btn-accent:focus-visible {
|
||||||
|
color: #06111d;
|
||||||
|
background: var(--accent-gradient-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-card {
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
background: var(--surface-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-rail article {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-control:focus {
|
||||||
|
color: var(--page-text);
|
||||||
|
background-color: var(--input-background);
|
||||||
|
border-color: var(--input-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: var(--placeholder-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label,
|
||||||
|
.h3,
|
||||||
|
.h4,
|
||||||
|
.display-5,
|
||||||
|
.fw-semibold,
|
||||||
|
.fw-bold {
|
||||||
|
color: var(--page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary,
|
||||||
|
.lead,
|
||||||
|
.small {
|
||||||
|
color: var(--page-text-muted) !important;
|
||||||
|
}
|
||||||
102
client/src/app/home-page.component.ts
Normal file
102
client/src/app/home-page.component.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, effect, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home-page',
|
||||||
|
imports: [CommonModule, FormsModule, RouterLink],
|
||||||
|
templateUrl: './home-page.component.html',
|
||||||
|
styleUrl: './home-page.component.scss',
|
||||||
|
})
|
||||||
|
export class HomePageComponent {
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
readonly theme = inject(ThemeService);
|
||||||
|
authMode: 'login' | 'register' = 'login';
|
||||||
|
readonly embeddedMode =
|
||||||
|
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
|
||||||
|
serverUrl = '';
|
||||||
|
displayName = '';
|
||||||
|
username = '';
|
||||||
|
password = '';
|
||||||
|
accessKeyLabel = '';
|
||||||
|
|
||||||
|
constructor(readonly session: ChatSessionService) {
|
||||||
|
this.serverUrl = session.serverUrl();
|
||||||
|
|
||||||
|
if (this.embeddedMode) {
|
||||||
|
effect(() => {
|
||||||
|
const currentUser = this.session.currentUser();
|
||||||
|
const activePeerId = this.session.activePeerId();
|
||||||
|
|
||||||
|
if (!currentUser || !activePeerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitAuth(): Promise<void> {
|
||||||
|
this.applyServerUrl();
|
||||||
|
|
||||||
|
if (this.authMode === 'register') {
|
||||||
|
const authenticated = await this.session.register(this.username, this.password, this.displayName);
|
||||||
|
this.password = '';
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
this.authMode = 'login';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.login(this.username, this.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyServerUrl(): void {
|
||||||
|
this.session.setServerUrl(this.serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await this.session.logout();
|
||||||
|
this.authMode = 'login';
|
||||||
|
this.displayName = '';
|
||||||
|
this.password = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithAccessKey(): Promise<void> {
|
||||||
|
this.applyServerUrl();
|
||||||
|
await this.session.loginWithAccessKey(this.username);
|
||||||
|
this.password = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerAccessKey(): Promise<void> {
|
||||||
|
await this.session.registerAccessKey(this.accessKeyLabel);
|
||||||
|
this.accessKeyLabel = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
canOpenChatUi(): boolean {
|
||||||
|
return this.session.peers().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openChatUi(): Promise<void> {
|
||||||
|
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
this.session.error.set('No connected peers are available yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.selectPeer(peerId);
|
||||||
|
await this.router.navigate(['/chat', peerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleTheme(): void {
|
||||||
|
this.theme.cycleMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
144
client/src/app/models.ts
Normal file
144
client/src/app/models.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'failed';
|
||||||
|
export type ChannelState = 'closed' | 'connecting' | 'open';
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerSummary extends UserProfile {
|
||||||
|
connectionState: ConnectionState;
|
||||||
|
channelState: ChannelState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
user: UserProfile;
|
||||||
|
messageEncryptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResponse {
|
||||||
|
user: UserProfile;
|
||||||
|
messageEncryptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalResponse {
|
||||||
|
pendingApproval: true;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessKeySummary {
|
||||||
|
id: string;
|
||||||
|
credentialId: string;
|
||||||
|
label: string;
|
||||||
|
transports: string[];
|
||||||
|
deviceType: string;
|
||||||
|
backedUp: boolean;
|
||||||
|
aaguid: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationOptionsResponse {
|
||||||
|
rp: PublicKeyCredentialRpEntity;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
challenge: string;
|
||||||
|
pubKeyCredParams: PublicKeyCredentialParameters[];
|
||||||
|
timeout?: number;
|
||||||
|
excludeCredentials?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: PublicKeyCredentialType;
|
||||||
|
transports?: string[];
|
||||||
|
}>;
|
||||||
|
authenticatorSelection?: AuthenticatorSelectionCriteria;
|
||||||
|
attestation?: AttestationConveyancePreference;
|
||||||
|
extensions?: AuthenticationExtensionsClientInputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticationOptionsResponse {
|
||||||
|
attemptId: string;
|
||||||
|
challenge: string;
|
||||||
|
timeout?: number;
|
||||||
|
rpId?: string;
|
||||||
|
allowCredentials?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: PublicKeyCredentialType;
|
||||||
|
transports?: string[];
|
||||||
|
}>;
|
||||||
|
userVerification?: UserVerificationRequirement;
|
||||||
|
hints?: string[];
|
||||||
|
extensions?: AuthenticationExtensionsClientInputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatEntry {
|
||||||
|
id: string;
|
||||||
|
peerId: string;
|
||||||
|
direction: 'incoming' | 'outgoing' | 'system';
|
||||||
|
kind: 'text' | 'json' | 'file' | 'system';
|
||||||
|
createdAt: number;
|
||||||
|
authorLabel: string;
|
||||||
|
text?: string;
|
||||||
|
payload?: unknown;
|
||||||
|
fileName?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
fileMimeType?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignalPayload =
|
||||||
|
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||||
|
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||||
|
|
||||||
|
export type ServerEvent =
|
||||||
|
| { type: 'presence'; self: UserProfile; peers: UserProfile[] }
|
||||||
|
| { type: 'peer-joined'; peer: UserProfile }
|
||||||
|
| { type: 'peer-left'; peerId: string }
|
||||||
|
| { type: 'signal'; from: string; signal: SignalPayload }
|
||||||
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
export type DataEnvelope =
|
||||||
|
| {
|
||||||
|
type: 'text';
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
sentAt: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'json';
|
||||||
|
id: string;
|
||||||
|
body: unknown;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
sentAt: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'file-meta';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
sentAt: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'file-complete';
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'typing';
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
110
client/src/app/theme.service.ts
Normal file
110
client/src/app/theme.service.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeService {
|
||||||
|
private readonly storageKey = 'privatechat.themeMode';
|
||||||
|
private readonly mediaQuery =
|
||||||
|
typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null;
|
||||||
|
|
||||||
|
readonly mode = signal<ThemeMode>(this.readStoredMode());
|
||||||
|
readonly resolvedTheme = computed<ResolvedTheme>(() => {
|
||||||
|
const mode = this.mode();
|
||||||
|
|
||||||
|
if (mode === 'system') {
|
||||||
|
return this.mediaQuery?.matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode === 'dark' ? 'dark' : 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly emoji = computed(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'light':
|
||||||
|
return '🌞';
|
||||||
|
case 'dark':
|
||||||
|
return '🌙';
|
||||||
|
default:
|
||||||
|
return '🖥️';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly nextMode = computed<ThemeMode>(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'light':
|
||||||
|
return 'dark';
|
||||||
|
case 'dark':
|
||||||
|
return 'system';
|
||||||
|
default:
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.applyTheme();
|
||||||
|
const mediaQuery = this.mediaQuery as
|
||||||
|
| (MediaQueryList & {
|
||||||
|
addListener?: (listener: () => void) => void;
|
||||||
|
})
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (mediaQuery) {
|
||||||
|
try {
|
||||||
|
mediaQuery.addEventListener('change', this.handleSystemThemeChange);
|
||||||
|
} catch {
|
||||||
|
mediaQuery.addListener?.(this.handleSystemThemeChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleMode(): void {
|
||||||
|
this.setMode(this.nextMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode: ThemeMode): void {
|
||||||
|
this.mode.set(mode);
|
||||||
|
this.writeStoredMode(mode);
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleSystemThemeChange = (): void => {
|
||||||
|
if (this.mode() === 'system') {
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private applyTheme(): void {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.dataset['themeMode'] = this.mode();
|
||||||
|
document.documentElement.dataset['theme'] = this.resolvedTheme();
|
||||||
|
document.documentElement.dataset['bsTheme'] = this.resolvedTheme();
|
||||||
|
document.documentElement.style.colorScheme = this.resolvedTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStoredMode(): ThemeMode {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(this.storageKey);
|
||||||
|
|
||||||
|
if (value === 'light' || value === 'dark' || value === 'system') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage access issues.
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeStoredMode(mode: ThemeMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.storageKey, mode);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage access issues.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
client/src/index.html
Normal file
17
client/src/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>PrivateChat</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<script src="env.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
client/src/main.ts
Normal file
6
client/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
179
client/src/styles.scss
Normal file
179
client/src/styles.scss
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
@use 'bootstrap/scss/bootstrap';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--page-text: #142236;
|
||||||
|
--page-text-muted: rgba(39, 63, 91, 0.72);
|
||||||
|
--page-text-soft: rgba(39, 63, 91, 0.82);
|
||||||
|
--page-background:
|
||||||
|
radial-gradient(circle at top left, rgba(81, 168, 255, 0.2), transparent 30%),
|
||||||
|
radial-gradient(circle at top right, rgba(129, 244, 215, 0.22), transparent 24%),
|
||||||
|
linear-gradient(180deg, #f6fbff 0%, #e8f1fb 100%);
|
||||||
|
--panel-background: rgba(255, 255, 255, 0.82);
|
||||||
|
--panel-alt-background: rgba(241, 247, 255, 0.9);
|
||||||
|
--panel-soft-background: rgba(20, 34, 54, 0.04);
|
||||||
|
--surface-background: rgba(255, 255, 255, 0.82);
|
||||||
|
--surface-hover-background: rgba(235, 244, 255, 0.98);
|
||||||
|
--surface-border: rgba(33, 62, 94, 0.12);
|
||||||
|
--surface-border-soft: rgba(33, 62, 94, 0.08);
|
||||||
|
--input-background: rgba(255, 255, 255, 0.92);
|
||||||
|
--input-border: rgba(77, 114, 154, 0.26);
|
||||||
|
--placeholder-color: rgba(55, 83, 118, 0.52);
|
||||||
|
--accent-color: #138a7b;
|
||||||
|
--accent-color-soft: rgba(19, 138, 123, 0.1);
|
||||||
|
--accent-gradient: linear-gradient(135deg, #8df0df, #6cb6ff);
|
||||||
|
--accent-gradient-hover: linear-gradient(135deg, #a6f5e8, #86c4ff);
|
||||||
|
--link-color: #2f7cd6;
|
||||||
|
--badge-background: rgba(20, 34, 54, 0.08);
|
||||||
|
--incoming-bubble-background: #d9ebff;
|
||||||
|
--incoming-bubble-text: #183759;
|
||||||
|
--outgoing-bubble-background: #d9f5df;
|
||||||
|
--outgoing-bubble-text: #1e4d2f;
|
||||||
|
--danger-background: #d94b53;
|
||||||
|
--shadow-color: rgba(41, 73, 110, 0.14);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme]) {
|
||||||
|
--page-text: #eff3ff;
|
||||||
|
--page-text-muted: rgba(231, 238, 249, 0.72);
|
||||||
|
--page-text-soft: rgba(231, 238, 249, 0.84);
|
||||||
|
--page-background:
|
||||||
|
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
|
||||||
|
linear-gradient(180deg, #08111d 0%, #101d31 100%);
|
||||||
|
--panel-background: rgba(9, 16, 28, 0.78);
|
||||||
|
--panel-alt-background: rgba(15, 27, 44, 0.78);
|
||||||
|
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
||||||
|
--surface-background: rgba(8, 14, 23, 0.7);
|
||||||
|
--surface-hover-background: rgba(16, 30, 49, 0.92);
|
||||||
|
--surface-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--surface-border-soft: rgba(255, 255, 255, 0.08);
|
||||||
|
--input-background: rgba(255, 255, 255, 0.06);
|
||||||
|
--input-border: rgba(255, 255, 255, 0.16);
|
||||||
|
--placeholder-color: rgba(239, 243, 255, 0.5);
|
||||||
|
--accent-color: #81f4d7;
|
||||||
|
--accent-color-soft: rgba(129, 244, 215, 0.1);
|
||||||
|
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
|
||||||
|
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
|
||||||
|
--link-color: #9bd5ff;
|
||||||
|
--badge-background: rgba(255, 255, 255, 0.08);
|
||||||
|
--incoming-bubble-background: #dcefff;
|
||||||
|
--incoming-bubble-text: #0f2540;
|
||||||
|
--outgoing-bubble-background: #def7dd;
|
||||||
|
--outgoing-bubble-text: #153420;
|
||||||
|
--danger-background: #d94b53;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.28);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--page-text: #eff3ff;
|
||||||
|
--page-text-muted: rgba(231, 238, 249, 0.72);
|
||||||
|
--page-text-soft: rgba(231, 238, 249, 0.84);
|
||||||
|
--page-background:
|
||||||
|
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
|
||||||
|
linear-gradient(180deg, #08111d 0%, #101d31 100%);
|
||||||
|
--panel-background: rgba(9, 16, 28, 0.78);
|
||||||
|
--panel-alt-background: rgba(15, 27, 44, 0.78);
|
||||||
|
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
||||||
|
--surface-background: rgba(8, 14, 23, 0.7);
|
||||||
|
--surface-hover-background: rgba(16, 30, 49, 0.92);
|
||||||
|
--surface-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--surface-border-soft: rgba(255, 255, 255, 0.08);
|
||||||
|
--input-background: rgba(255, 255, 255, 0.06);
|
||||||
|
--input-border: rgba(255, 255, 255, 0.16);
|
||||||
|
--placeholder-color: rgba(239, 243, 255, 0.5);
|
||||||
|
--accent-color: #81f4d7;
|
||||||
|
--accent-color-soft: rgba(129, 244, 215, 0.1);
|
||||||
|
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
|
||||||
|
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
|
||||||
|
--link-color: #9bd5ff;
|
||||||
|
--badge-background: rgba(255, 255, 255, 0.08);
|
||||||
|
--incoming-bubble-background: #dcefff;
|
||||||
|
--incoming-bubble-text: #0f2540;
|
||||||
|
--outgoing-bubble-background: #def7dd;
|
||||||
|
--outgoing-bubble-text: #153420;
|
||||||
|
--danger-background: #d94b53;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.28);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--page-text);
|
||||||
|
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
background: var(--page-background);
|
||||||
|
background-attachment: fixed;
|
||||||
|
transition:
|
||||||
|
background 180ms ease,
|
||||||
|
color 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--page-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-bg-dark {
|
||||||
|
color: var(--page-text) !important;
|
||||||
|
background: var(--badge-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-light {
|
||||||
|
color: var(--page-text);
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-light:hover,
|
||||||
|
.btn-outline-light:focus-visible {
|
||||||
|
color: var(--page-text);
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: var(--page-text-muted);
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary:hover,
|
||||||
|
.btn-outline-secondary:focus-visible {
|
||||||
|
color: var(--page-text);
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
color: var(--link-color);
|
||||||
|
border-color: color-mix(in srgb, var(--link-color) 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger,
|
||||||
|
.alert-success,
|
||||||
|
.alert-warning {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
15
client/tsconfig.app.json
Normal file
15
client/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
30
client/tsconfig.json
Normal file
30
client/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
client/tsconfig.spec.json
Normal file
15
client/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
329
package-lock.json
generated
Normal file
329
package-lock.json
generated
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
{
|
||||||
|
"name": "private-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "private-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "9.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
|
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "4.1.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"shell-quote": "1.8.3",
|
||||||
|
"supports-color": "8.1.1",
|
||||||
|
"tree-kill": "1.2.2",
|
||||||
|
"yargs": "17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/concurrently.js",
|
||||||
|
"concurrently": "dist/bin/concurrently.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "private-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node node_modules/concurrently/dist/bin/concurrently.js -k -n server,client -c green,blue \"npm run dev --prefix server\" \"npm run start --prefix client\"",
|
||||||
|
"dev:server": "npm run dev --prefix server",
|
||||||
|
"dev:client": "npm run start --prefix client",
|
||||||
|
"build": "npm run build --prefix server && npm run build --prefix client",
|
||||||
|
"start": "npm run build && npm run start --prefix server"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1026
server/dist/index.js
vendored
Normal file
1026
server/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2166
server/package-lock.json
generated
Normal file
2166
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "private-chat-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node node_modules/tsx/dist/cli.mjs watch src/index.ts",
|
||||||
|
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
|
"@simplewebauthn/server": "^13.2.3",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"fastify": "^5.8.2",
|
||||||
|
"ioredis": "^5.10.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.5",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
1540
server/src/index.ts
Normal file
1540
server/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user