Build passport-style identity app shell
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
359
IDPGlobal.xcodeproj/project.pbxproj
Normal file
359
IDPGlobal.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* IDPGlobalApp.swift */; };
|
||||||
|
B10000000000000000000002 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* AppViewModel.swift */; };
|
||||||
|
B10000000000000000000003 /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* AppModels.swift */; };
|
||||||
|
B10000000000000000000004 /* MockIDPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* MockIDPService.swift */; };
|
||||||
|
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* NotificationCoordinator.swift */; };
|
||||||
|
B10000000000000000000006 /* LoginRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* LoginRootView.swift */; };
|
||||||
|
B10000000000000000000007 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* QRScannerView.swift */; };
|
||||||
|
B10000000000000000000008 /* HomeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* HomeRootView.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
B20000000000000000000001 /* IDPGlobalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDPGlobalApp.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000003 /* AppModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModels.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000004 /* MockIDPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIDPService.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000005 /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000006 /* LoginRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginRootView.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000007 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000008 /* HomeRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeRootView.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000009 /* IDPGlobal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IDPGlobal.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
B30000000000000000000001 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
B40000000000000000000001 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B40000000000000000000002 /* IDPGlobal */,
|
||||||
|
B40000000000000000000009 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000002 /* IDPGlobal */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B40000000000000000000003 /* Sources */,
|
||||||
|
);
|
||||||
|
name = IDPGlobal;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000003 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B40000000000000000000004 /* App */,
|
||||||
|
B40000000000000000000005 /* Core */,
|
||||||
|
B40000000000000000000008 /* Features */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000004 /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B20000000000000000000001 /* IDPGlobalApp.swift */,
|
||||||
|
B20000000000000000000002 /* AppViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000005 /* Core */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B40000000000000000000006 /* Models */,
|
||||||
|
B40000000000000000000007 /* Services */,
|
||||||
|
);
|
||||||
|
path = Core;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000006 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B20000000000000000000003 /* AppModels.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000007 /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B20000000000000000000004 /* MockIDPService.swift */,
|
||||||
|
B20000000000000000000005 /* NotificationCoordinator.swift */,
|
||||||
|
);
|
||||||
|
path = Services;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000008 /* Features */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B4000000000000000000000A /* Auth */,
|
||||||
|
B4000000000000000000000B /* Home */,
|
||||||
|
);
|
||||||
|
path = Features;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B40000000000000000000009 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B20000000000000000000009 /* IDPGlobal.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B4000000000000000000000A /* Auth */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B20000000000000000000006 /* LoginRootView.swift */,
|
||||||
|
B20000000000000000000007 /* QRScannerView.swift */,
|
||||||
|
);
|
||||||
|
path = Auth;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B4000000000000000000000B /* Home */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B20000000000000000000008 /* HomeRootView.swift */,
|
||||||
|
);
|
||||||
|
path = Home;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
B50000000000000000000001 /* IDPGlobal */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = B70000000000000000000002 /* Build configuration list for PBXNativeTarget "IDPGlobal" */;
|
||||||
|
buildPhases = (
|
||||||
|
B30000000000000000000002 /* Sources */,
|
||||||
|
B30000000000000000000001 /* Frameworks */,
|
||||||
|
B30000000000000000000003 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = IDPGlobal;
|
||||||
|
productName = IDPGlobal;
|
||||||
|
productReference = B20000000000000000000009 /* IDPGlobal.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
B60000000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2600;
|
||||||
|
LastUpgradeCheck = 2600;
|
||||||
|
TargetAttributes = {
|
||||||
|
B50000000000000000000001 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = B40000000000000000000001;
|
||||||
|
productRefGroup = B40000000000000000000009 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
B50000000000000000000001 /* IDPGlobal */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
B30000000000000000000003 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
B30000000000000000000002 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
B10000000000000000000002 /* AppViewModel.swift in Sources */,
|
||||||
|
B10000000000000000000008 /* HomeRootView.swift in Sources */,
|
||||||
|
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */,
|
||||||
|
B10000000000000000000006 /* LoginRootView.swift in Sources */,
|
||||||
|
B10000000000000000000004 /* MockIDPService.swift in Sources */,
|
||||||
|
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */,
|
||||||
|
B10000000000000000000003 /* AppModels.swift in Sources */,
|
||||||
|
B10000000000000000000007 /* QRScannerView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
B80000000000000000000001 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
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;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
B80000000000000000000002 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
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;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
B80000000000000000000003 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBSERVATION_ENABLED = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
B80000000000000000000004 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBSERVATION_ENABLED = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
B80000000000000000000001 /* Debug */,
|
||||||
|
B80000000000000000000002 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
B70000000000000000000002 /* Build configuration list for PBXNativeTarget "IDPGlobal" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
B80000000000000000000003 /* Debug */,
|
||||||
|
B80000000000000000000004 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = B60000000000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
34
README.md
Normal file
34
README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# idp.global Swift App
|
||||||
|
|
||||||
|
Multiplatform SwiftUI scaffold for the personal `idp.global` companion app on iPhone, iPad, and Mac.
|
||||||
|
|
||||||
|
## Included in this first pass
|
||||||
|
|
||||||
|
- QR-based sign-in flow with a live camera scanner and a seeded mock QR payload fallback
|
||||||
|
- Mocked approval inbox for accepting or rejecting identity requests
|
||||||
|
- Notification center with local notification permission flow and a test notification trigger
|
||||||
|
- Shared app state and mock backend boundary so a real API can be connected later
|
||||||
|
|
||||||
|
## Open the project
|
||||||
|
|
||||||
|
1. Open [IDPGlobal.xcodeproj](/Users/philkunz/gitea/idp.global-swiftapp/IDPGlobal/IDPGlobal.xcodeproj).
|
||||||
|
2. Build the `IDPGlobal` scheme for:
|
||||||
|
- `My Mac`
|
||||||
|
- an iPad simulator
|
||||||
|
- an iPhone simulator
|
||||||
|
|
||||||
|
## Mock QR payload
|
||||||
|
|
||||||
|
The app seeds this pairing payload on first launch:
|
||||||
|
|
||||||
|
`idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP`
|
||||||
|
|
||||||
|
You can paste it manually or use the "Use Mock QR" action while the backend is still mocked.
|
||||||
|
|
||||||
|
## Next integration step
|
||||||
|
|
||||||
|
Replace `MockIDPService` with a live service that:
|
||||||
|
|
||||||
|
- exchanges the QR payload for a session token
|
||||||
|
- loads approval requests and notifications from the backend
|
||||||
|
- posts approval decisions back to `idp.global`
|
||||||
238
Sources/App/AppViewModel.swift
Normal file
238
Sources/App/AppViewModel.swift
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppViewModel: ObservableObject {
|
||||||
|
@Published var suggestedQRCodePayload = ""
|
||||||
|
@Published var manualQRCodePayload = ""
|
||||||
|
@Published var session: AuthSession?
|
||||||
|
@Published var profile: MemberProfile?
|
||||||
|
@Published var requests: [ApprovalRequest] = []
|
||||||
|
@Published var notifications: [AppNotification] = []
|
||||||
|
@Published var notificationPermission: NotificationPermissionState = .unknown
|
||||||
|
@Published var selectedSection: AppSection = .overview
|
||||||
|
@Published var isBootstrapping = false
|
||||||
|
@Published var isAuthenticating = false
|
||||||
|
@Published var isRefreshing = false
|
||||||
|
@Published var isNotificationCenterPresented = false
|
||||||
|
@Published var activeRequestID: ApprovalRequest.ID?
|
||||||
|
@Published var isScannerPresented = false
|
||||||
|
@Published var bannerMessage: String?
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
private var hasBootstrapped = false
|
||||||
|
private let service: IDPServicing
|
||||||
|
private let notificationCoordinator: NotificationCoordinating
|
||||||
|
private let launchArguments: [String]
|
||||||
|
|
||||||
|
private var preferredLaunchSection: AppSection? {
|
||||||
|
guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawValue = String(argument.dropFirst("--mock-section=".count))
|
||||||
|
if rawValue == "notifications" {
|
||||||
|
return .activity
|
||||||
|
}
|
||||||
|
return AppSection(rawValue: rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
service: IDPServicing = MockIDPService(),
|
||||||
|
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
||||||
|
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
||||||
|
) {
|
||||||
|
self.service = service
|
||||||
|
self.notificationCoordinator = notificationCoordinator
|
||||||
|
self.launchArguments = launchArguments
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingRequests: [ApprovalRequest] {
|
||||||
|
requests
|
||||||
|
.filter { $0.status == .pending }
|
||||||
|
.sorted { $0.createdAt > $1.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
var handledRequests: [ApprovalRequest] {
|
||||||
|
requests
|
||||||
|
.filter { $0.status != .pending }
|
||||||
|
.sorted { $0.createdAt > $1.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
var unreadNotificationCount: Int {
|
||||||
|
notifications.filter(\.isUnread).count
|
||||||
|
}
|
||||||
|
|
||||||
|
var elevatedPendingCount: Int {
|
||||||
|
pendingRequests.filter { $0.risk == .elevated }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestNotification: AppNotification? {
|
||||||
|
notifications.first
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairedDeviceSummary: String {
|
||||||
|
session?.deviceName ?? "No active device"
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrap() async {
|
||||||
|
guard !hasBootstrapped else { return }
|
||||||
|
hasBootstrapped = true
|
||||||
|
|
||||||
|
isBootstrapping = true
|
||||||
|
defer { isBootstrapping = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let bootstrap = try await service.bootstrap()
|
||||||
|
suggestedQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||||
|
manualQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||||
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||||
|
|
||||||
|
if launchArguments.contains("--mock-auto-pair"),
|
||||||
|
session == nil {
|
||||||
|
await signIn(with: bootstrap.suggestedQRCodePayload)
|
||||||
|
|
||||||
|
if let preferredLaunchSection {
|
||||||
|
selectedSection = preferredLaunchSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to prepare the app."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signInWithManualCode() async {
|
||||||
|
await signIn(with: manualQRCodePayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signInWithSuggestedCode() async {
|
||||||
|
manualQRCodePayload = suggestedQRCodePayload
|
||||||
|
await signIn(with: suggestedQRCodePayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(with payload: String) async {
|
||||||
|
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
errorMessage = "Paste or scan a QR payload first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticating = true
|
||||||
|
defer { isAuthenticating = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await service.signIn(withQRCode: trimmed)
|
||||||
|
session = result.session
|
||||||
|
apply(snapshot: result.snapshot)
|
||||||
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||||
|
selectedSection = .overview
|
||||||
|
bannerMessage = "Paired with \(result.session.deviceName)."
|
||||||
|
isScannerPresented = false
|
||||||
|
} catch let error as AppError {
|
||||||
|
errorMessage = error.errorDescription
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to complete sign-in."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshDashboard() async {
|
||||||
|
guard session != nil else { return }
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
defer { isRefreshing = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let snapshot = try await service.refreshDashboard()
|
||||||
|
apply(snapshot: snapshot)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to refresh the dashboard."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func approve(_ request: ApprovalRequest) async {
|
||||||
|
await mutateRequest(request, approve: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reject(_ request: ApprovalRequest) async {
|
||||||
|
await mutateRequest(request, approve: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateIncomingRequest() async {
|
||||||
|
guard session != nil else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let snapshot = try await service.simulateIncomingRequest()
|
||||||
|
apply(snapshot: snapshot)
|
||||||
|
selectedSection = .requests
|
||||||
|
bannerMessage = "A new mock approval request arrived."
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to seed a new request right now."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotificationAccess() async {
|
||||||
|
do {
|
||||||
|
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
||||||
|
if notificationPermission == .allowed || notificationPermission == .provisional {
|
||||||
|
bannerMessage = "Notifications are ready on this device."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to update notification permission."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendTestNotification() async {
|
||||||
|
do {
|
||||||
|
try await notificationCoordinator.scheduleTestNotification(
|
||||||
|
title: "idp.global approval pending",
|
||||||
|
body: "A mock request is waiting for approval in the app."
|
||||||
|
)
|
||||||
|
bannerMessage = "A local test notification will appear in a few seconds."
|
||||||
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to schedule a test notification."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markNotificationRead(_ notification: AppNotification) async {
|
||||||
|
do {
|
||||||
|
let snapshot = try await service.markNotificationRead(id: notification.id)
|
||||||
|
apply(snapshot: snapshot)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to update the notification."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() {
|
||||||
|
session = nil
|
||||||
|
profile = nil
|
||||||
|
requests = []
|
||||||
|
notifications = []
|
||||||
|
selectedSection = .overview
|
||||||
|
bannerMessage = nil
|
||||||
|
manualQRCodePayload = suggestedQRCodePayload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
||||||
|
guard session != nil else { return }
|
||||||
|
|
||||||
|
activeRequestID = request.id
|
||||||
|
defer { activeRequestID = nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let snapshot = approve
|
||||||
|
? try await service.approveRequest(id: request.id)
|
||||||
|
: try await service.rejectRequest(id: request.id)
|
||||||
|
apply(snapshot: snapshot)
|
||||||
|
bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)."
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to update the request."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(snapshot: DashboardSnapshot) {
|
||||||
|
profile = snapshot.profile
|
||||||
|
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||||
|
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Sources/App/IDPGlobalApp.swift
Normal file
63
Sources/App/IDPGlobalApp.swift
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct IDPGlobalApp: App {
|
||||||
|
@StateObject private var model = AppViewModel()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView(model: model)
|
||||||
|
.tint(Color(red: 0.12, green: 0.40, blue: 0.31))
|
||||||
|
.task {
|
||||||
|
await model.bootstrap()
|
||||||
|
}
|
||||||
|
.alert("Something went wrong", isPresented: errorPresented) {
|
||||||
|
Button("OK") {
|
||||||
|
model.errorMessage = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(model.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.defaultSize(width: 1380, height: 920)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var errorPresented: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { model.errorMessage != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
model.errorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RootView: View {
|
||||||
|
@ObservedObject var model: AppViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if model.session == nil {
|
||||||
|
LoginRootView(model: model)
|
||||||
|
} else {
|
||||||
|
HomeRootView(model: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.96, green: 0.97, blue: 0.94),
|
||||||
|
Color(red: 0.89, green: 0.94, blue: 0.92),
|
||||||
|
Color(red: 0.94, green: 0.91, blue: 0.84)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
346
Sources/Core/Models/AppModels.swift
Normal file
346
Sources/Core/Models/AppModels.swift
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
case overview
|
||||||
|
case requests
|
||||||
|
case activity
|
||||||
|
case account
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .overview: "Passport"
|
||||||
|
case .requests: "Requests"
|
||||||
|
case .activity: "Activity"
|
||||||
|
case .account: "Account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .overview: "person.crop.square.fill"
|
||||||
|
case .requests: "checklist.checked"
|
||||||
|
case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90"
|
||||||
|
case .account: "person.crop.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationPermissionState: String, CaseIterable, Identifiable {
|
||||||
|
case unknown
|
||||||
|
case allowed
|
||||||
|
case provisional
|
||||||
|
case denied
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .unknown: "Not Asked Yet"
|
||||||
|
case .allowed: "Enabled"
|
||||||
|
case .provisional: "Delivered Quietly"
|
||||||
|
case .denied: "Disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .unknown: "bell"
|
||||||
|
case .allowed: "bell.badge.fill"
|
||||||
|
case .provisional: "bell.badge"
|
||||||
|
case .denied: "bell.slash.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch self {
|
||||||
|
case .unknown:
|
||||||
|
"The app has not asked for notification delivery yet."
|
||||||
|
case .allowed:
|
||||||
|
"Alerts can break through immediately when a request arrives."
|
||||||
|
case .provisional:
|
||||||
|
"Notifications can be delivered quietly until the user promotes them."
|
||||||
|
case .denied:
|
||||||
|
"Approval events stay in-app until the user re-enables notifications."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BootstrapContext {
|
||||||
|
let suggestedQRCodePayload: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardSnapshot {
|
||||||
|
let profile: MemberProfile
|
||||||
|
let requests: [ApprovalRequest]
|
||||||
|
let notifications: [AppNotification]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SignInResult {
|
||||||
|
let session: AuthSession
|
||||||
|
let snapshot: DashboardSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MemberProfile: Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
let name: String
|
||||||
|
let handle: String
|
||||||
|
let organization: String
|
||||||
|
let deviceCount: Int
|
||||||
|
let recoverySummary: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
handle: String,
|
||||||
|
organization: String,
|
||||||
|
deviceCount: Int,
|
||||||
|
recoverySummary: String
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.handle = handle
|
||||||
|
self.organization = organization
|
||||||
|
self.deviceCount = deviceCount
|
||||||
|
self.recoverySummary = recoverySummary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthSession: Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
let deviceName: String
|
||||||
|
let originHost: String
|
||||||
|
let pairedAt: Date
|
||||||
|
let tokenPreview: String
|
||||||
|
let pairingCode: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
deviceName: String,
|
||||||
|
originHost: String,
|
||||||
|
pairedAt: Date,
|
||||||
|
tokenPreview: String,
|
||||||
|
pairingCode: String
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.deviceName = deviceName
|
||||||
|
self.originHost = originHost
|
||||||
|
self.pairedAt = pairedAt
|
||||||
|
self.tokenPreview = tokenPreview
|
||||||
|
self.pairingCode = pairingCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||||
|
case signIn
|
||||||
|
case accessGrant
|
||||||
|
case elevatedAction
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .signIn: "Sign-In"
|
||||||
|
case .accessGrant: "Access Grant"
|
||||||
|
case .elevatedAction: "Elevated Action"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .signIn: "qrcode.viewfinder"
|
||||||
|
case .accessGrant: "key.fill"
|
||||||
|
case .elevatedAction: "shield.lefthalf.filled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApprovalRisk: String, Hashable {
|
||||||
|
case routine
|
||||||
|
case elevated
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .routine: "Routine"
|
||||||
|
case .elevated: "Elevated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch self {
|
||||||
|
case .routine:
|
||||||
|
"Routine access to profile or sign-in scopes."
|
||||||
|
case .elevated:
|
||||||
|
"Sensitive access that can sign, publish, or unlock privileged actions."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var guidance: String {
|
||||||
|
switch self {
|
||||||
|
case .routine:
|
||||||
|
"Review the origin and scope list, then approve if the session matches the device you expect."
|
||||||
|
case .elevated:
|
||||||
|
"Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApprovalStatus: String, Hashable {
|
||||||
|
case pending
|
||||||
|
case approved
|
||||||
|
case rejected
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: "Pending"
|
||||||
|
case .approved: "Approved"
|
||||||
|
case .rejected: "Rejected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: "clock.badge"
|
||||||
|
case .approved: "checkmark.circle.fill"
|
||||||
|
case .rejected: "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApprovalRequest: Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let source: String
|
||||||
|
let createdAt: Date
|
||||||
|
let kind: ApprovalRequestKind
|
||||||
|
let risk: ApprovalRisk
|
||||||
|
let scopes: [String]
|
||||||
|
var status: ApprovalStatus
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
source: String,
|
||||||
|
createdAt: Date,
|
||||||
|
kind: ApprovalRequestKind,
|
||||||
|
risk: ApprovalRisk,
|
||||||
|
scopes: [String],
|
||||||
|
status: ApprovalStatus
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.source = source
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.kind = kind
|
||||||
|
self.risk = risk
|
||||||
|
self.scopes = scopes
|
||||||
|
self.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeSummary: String {
|
||||||
|
if scopes.isEmpty {
|
||||||
|
return "No scopes listed"
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = scopes.count == 1 ? "" : "s"
|
||||||
|
return "\(scopes.count) requested scope\(suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var trustHeadline: String {
|
||||||
|
switch (kind, risk) {
|
||||||
|
case (.signIn, .routine):
|
||||||
|
"Low-friction sign-in request"
|
||||||
|
case (.signIn, .elevated):
|
||||||
|
"Privileged sign-in request"
|
||||||
|
case (.accessGrant, _):
|
||||||
|
"Token grant request"
|
||||||
|
case (.elevatedAction, _):
|
||||||
|
"Sensitive action request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var trustDetail: String {
|
||||||
|
switch kind {
|
||||||
|
case .signIn:
|
||||||
|
"This request usually creates or refreshes a session token for a browser, CLI, or device."
|
||||||
|
case .accessGrant:
|
||||||
|
"This request issues scoped access for a service or automation that wants to act on your behalf."
|
||||||
|
case .elevatedAction:
|
||||||
|
"This request performs a privileged action such as signing, publishing, or creating short-lived credentials."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppNotificationKind: String, Hashable {
|
||||||
|
case approval
|
||||||
|
case security
|
||||||
|
case system
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .approval: "Approval"
|
||||||
|
case .security: "Security"
|
||||||
|
case .system: "System"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .approval: "checkmark.seal.fill"
|
||||||
|
case .security: "shield.fill"
|
||||||
|
case .system: "sparkles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch self {
|
||||||
|
case .approval:
|
||||||
|
"Decision and approval activity"
|
||||||
|
case .security:
|
||||||
|
"Pairing and security posture updates"
|
||||||
|
case .system:
|
||||||
|
"Product and environment status messages"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppNotification: Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
let sentAt: Date
|
||||||
|
let kind: AppNotificationKind
|
||||||
|
var isUnread: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
sentAt: Date,
|
||||||
|
kind: AppNotificationKind,
|
||||||
|
isUnread: Bool
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.message = message
|
||||||
|
self.sentAt = sentAt
|
||||||
|
self.kind = kind
|
||||||
|
self.isUnread = isUnread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppError: LocalizedError {
|
||||||
|
case invalidQRCode
|
||||||
|
case requestNotFound
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidQRCode:
|
||||||
|
"That QR payload is not valid for idp.global sign-in."
|
||||||
|
case .requestNotFound:
|
||||||
|
"The selected request could not be found."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
Sources/Core/Services/MockIDPService.swift
Normal file
246
Sources/Core/Services/MockIDPService.swift
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol IDPServicing {
|
||||||
|
func bootstrap() async throws -> BootstrapContext
|
||||||
|
func signIn(withQRCode payload: String) async throws -> SignInResult
|
||||||
|
func refreshDashboard() async throws -> DashboardSnapshot
|
||||||
|
func approveRequest(id: UUID) async throws -> DashboardSnapshot
|
||||||
|
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
|
||||||
|
func simulateIncomingRequest() async throws -> DashboardSnapshot
|
||||||
|
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
actor MockIDPService: IDPServicing {
|
||||||
|
private let profile = MemberProfile(
|
||||||
|
name: "Phil Kunz",
|
||||||
|
handle: "phil@idp.global",
|
||||||
|
organization: "idp.global",
|
||||||
|
deviceCount: 4,
|
||||||
|
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
|
||||||
|
)
|
||||||
|
|
||||||
|
private var requests: [ApprovalRequest] = []
|
||||||
|
private var notifications: [AppNotification] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
requests = Self.seedRequests()
|
||||||
|
notifications = Self.seedNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrap() async throws -> BootstrapContext {
|
||||||
|
try await Task.sleep(for: .milliseconds(120))
|
||||||
|
return BootstrapContext(
|
||||||
|
suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(withQRCode payload: String) async throws -> SignInResult {
|
||||||
|
try await Task.sleep(for: .milliseconds(260))
|
||||||
|
|
||||||
|
let session = try parseSession(from: payload)
|
||||||
|
notifications.insert(
|
||||||
|
AppNotification(
|
||||||
|
title: "New device paired",
|
||||||
|
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
|
||||||
|
sentAt: .now,
|
||||||
|
kind: .security,
|
||||||
|
isUnread: true
|
||||||
|
),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return SignInResult(
|
||||||
|
session: session,
|
||||||
|
snapshot: snapshot()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshDashboard() async throws -> DashboardSnapshot {
|
||||||
|
try await Task.sleep(for: .milliseconds(180))
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
|
||||||
|
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||||
|
throw AppError.requestNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
requests[index].status = .approved
|
||||||
|
notifications.insert(
|
||||||
|
AppNotification(
|
||||||
|
title: "Request approved",
|
||||||
|
message: "\(requests[index].title) was approved for \(requests[index].source).",
|
||||||
|
sentAt: .now,
|
||||||
|
kind: .approval,
|
||||||
|
isUnread: true
|
||||||
|
),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
|
||||||
|
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||||
|
throw AppError.requestNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
requests[index].status = .rejected
|
||||||
|
notifications.insert(
|
||||||
|
AppNotification(
|
||||||
|
title: "Request rejected",
|
||||||
|
message: "\(requests[index].title) was rejected before token issuance.",
|
||||||
|
sentAt: .now,
|
||||||
|
kind: .security,
|
||||||
|
isUnread: true
|
||||||
|
),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateIncomingRequest() async throws -> DashboardSnapshot {
|
||||||
|
try await Task.sleep(for: .milliseconds(120))
|
||||||
|
|
||||||
|
let syntheticRequest = ApprovalRequest(
|
||||||
|
title: "Approve SSH certificate issue",
|
||||||
|
subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.",
|
||||||
|
source: "deploy.idp.global",
|
||||||
|
createdAt: .now,
|
||||||
|
kind: .elevatedAction,
|
||||||
|
risk: .elevated,
|
||||||
|
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
|
||||||
|
status: .pending
|
||||||
|
)
|
||||||
|
|
||||||
|
requests.insert(syntheticRequest, at: 0)
|
||||||
|
notifications.insert(
|
||||||
|
AppNotification(
|
||||||
|
title: "Fresh approval request",
|
||||||
|
message: "A staging deployment is waiting for your approval.",
|
||||||
|
sentAt: .now,
|
||||||
|
kind: .approval,
|
||||||
|
isUnread: true
|
||||||
|
),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
|
||||||
|
try await Task.sleep(for: .milliseconds(80))
|
||||||
|
|
||||||
|
guard let index = notifications.firstIndex(where: { $0.id == id }) else {
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications[index].isUnread = false
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func snapshot() -> DashboardSnapshot {
|
||||||
|
DashboardSnapshot(
|
||||||
|
profile: profile,
|
||||||
|
requests: requests,
|
||||||
|
notifications: notifications
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseSession(from payload: String) throws -> AuthSession {
|
||||||
|
if let components = URLComponents(string: payload),
|
||||||
|
components.scheme == "idp.global",
|
||||||
|
components.host == "pair" {
|
||||||
|
let queryItems = components.queryItems ?? []
|
||||||
|
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
|
||||||
|
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||||
|
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||||
|
|
||||||
|
return AuthSession(
|
||||||
|
deviceName: device,
|
||||||
|
originHost: origin,
|
||||||
|
pairedAt: .now,
|
||||||
|
tokenPreview: String(token.suffix(6)),
|
||||||
|
pairingCode: payload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.contains("token") || payload.contains("pair") {
|
||||||
|
return AuthSession(
|
||||||
|
deviceName: "Manual Pairing",
|
||||||
|
originHost: "code.foss.global",
|
||||||
|
pairedAt: .now,
|
||||||
|
tokenPreview: String(payload.suffix(6)),
|
||||||
|
pairingCode: payload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw AppError.invalidQRCode
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func seedRequests() -> [ApprovalRequest] {
|
||||||
|
[
|
||||||
|
ApprovalRequest(
|
||||||
|
title: "Approve Safari sign-in",
|
||||||
|
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
|
||||||
|
source: "code.foss.global",
|
||||||
|
createdAt: .now.addingTimeInterval(-60 * 12),
|
||||||
|
kind: .signIn,
|
||||||
|
risk: .routine,
|
||||||
|
scopes: ["openid", "profile", "groups:read"],
|
||||||
|
status: .pending
|
||||||
|
),
|
||||||
|
ApprovalRequest(
|
||||||
|
title: "Grant package publish access",
|
||||||
|
subtitle: "The release bot is asking for a scoped publish token.",
|
||||||
|
source: "registry.foss.global",
|
||||||
|
createdAt: .now.addingTimeInterval(-60 * 42),
|
||||||
|
kind: .accessGrant,
|
||||||
|
risk: .elevated,
|
||||||
|
scopes: ["packages:write", "ttl:30m"],
|
||||||
|
status: .pending
|
||||||
|
),
|
||||||
|
ApprovalRequest(
|
||||||
|
title: "Approve CLI login",
|
||||||
|
subtitle: "A terminal session completed QR pairing earlier today.",
|
||||||
|
source: "cli.idp.global",
|
||||||
|
createdAt: .now.addingTimeInterval(-60 * 180),
|
||||||
|
kind: .signIn,
|
||||||
|
risk: .routine,
|
||||||
|
scopes: ["openid", "profile"],
|
||||||
|
status: .approved
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func seedNotifications() -> [AppNotification] {
|
||||||
|
[
|
||||||
|
AppNotification(
|
||||||
|
title: "Two requests are waiting",
|
||||||
|
message: "The queue includes one routine sign-in and one elevated access grant.",
|
||||||
|
sentAt: .now.addingTimeInterval(-60 * 8),
|
||||||
|
kind: .approval,
|
||||||
|
isUnread: true
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
title: "Recovery health check passed",
|
||||||
|
message: "Backup recovery channels were verified in the last 24 hours.",
|
||||||
|
sentAt: .now.addingTimeInterval(-60 * 95),
|
||||||
|
kind: .system,
|
||||||
|
isUnread: false
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
title: "Quiet hours active on mobile",
|
||||||
|
message: "Routine notifications will be delivered silently until the morning.",
|
||||||
|
sentAt: .now.addingTimeInterval(-60 * 220),
|
||||||
|
kind: .security,
|
||||||
|
isUnread: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Sources/Core/Services/NotificationCoordinator.swift
Normal file
54
Sources/Core/Services/NotificationCoordinator.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
protocol NotificationCoordinating {
|
||||||
|
func authorizationStatus() async -> NotificationPermissionState
|
||||||
|
func requestAuthorization() async throws -> NotificationPermissionState
|
||||||
|
func scheduleTestNotification(title: String, body: String) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
final class NotificationCoordinator: NotificationCoordinating {
|
||||||
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
func authorizationStatus() async -> NotificationPermissionState {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
return NotificationPermissionState(settings.authorizationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() async throws -> NotificationPermissionState {
|
||||||
|
_ = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||||
|
return await authorizationStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleTestNotification(title: String, body: String) async throws {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: UUID().uuidString,
|
||||||
|
content: content,
|
||||||
|
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false)
|
||||||
|
)
|
||||||
|
|
||||||
|
try await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension NotificationPermissionState {
|
||||||
|
init(_ status: UNAuthorizationStatus) {
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
self = .allowed
|
||||||
|
case .provisional, .ephemeral:
|
||||||
|
self = .provisional
|
||||||
|
case .denied:
|
||||||
|
self = .denied
|
||||||
|
case .notDetermined:
|
||||||
|
self = .unknown
|
||||||
|
@unknown default:
|
||||||
|
self = .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
300
Sources/Features/Auth/LoginRootView.swift
Normal file
300
Sources/Features/Auth/LoginRootView.swift
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
||||||
|
private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60)
|
||||||
|
|
||||||
|
struct LoginRootView: View {
|
||||||
|
@ObservedObject var model: AppViewModel
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: compactLayout ? 18 : 24) {
|
||||||
|
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
||||||
|
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
||||||
|
TrustFootprintCard(model: model, compactLayout: compactLayout)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 1040)
|
||||||
|
.padding(compactLayout ? 18 : 28)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $model.isScannerPresented) {
|
||||||
|
QRScannerSheet(
|
||||||
|
seededPayload: model.suggestedQRCodePayload,
|
||||||
|
onCodeScanned: { payload in
|
||||||
|
model.manualQRCodePayload = payload
|
||||||
|
Task {
|
||||||
|
await model.signIn(with: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactLayout: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
horizontalSizeClass == .compact
|
||||||
|
#else
|
||||||
|
false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LoginHeroPanel: View {
|
||||||
|
@ObservedObject var model: AppViewModel
|
||||||
|
let compactLayout: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
RoundedRectangle(cornerRadius: 36, style: .continuous)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.13, green: 0.22, blue: 0.19),
|
||||||
|
Color(red: 0.20, green: 0.41, blue: 0.33),
|
||||||
|
loginGold
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) {
|
||||||
|
Text("Bind this device to your idp.global account")
|
||||||
|
.font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Scan the pairing QR from your account to turn this device into your approval and notification app.")
|
||||||
|
.font(compactLayout ? .body : .title3)
|
||||||
|
.foregroundStyle(.white.opacity(0.88))
|
||||||
|
|
||||||
|
if compactLayout {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HeroTag(title: "Account binding")
|
||||||
|
HeroTag(title: "QR pairing")
|
||||||
|
HeroTag(title: "iPhone, iPad, Mac")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
HeroTag(title: "Account binding")
|
||||||
|
HeroTag(title: "QR pairing")
|
||||||
|
HeroTag(title: "iPhone, iPad, Mac")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.isBootstrapping {
|
||||||
|
ProgressView("Preparing preview pairing payload…")
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(compactLayout ? 22 : 32)
|
||||||
|
}
|
||||||
|
.frame(minHeight: compactLayout ? 280 : 320)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairingConsoleCard: View {
|
||||||
|
@ObservedObject var model: AppViewModel
|
||||||
|
let compactLayout: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Open your account pairing screen, then scan the QR code here.")
|
||||||
|
.font(.headline)
|
||||||
|
Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextEditor(text: $model.manualQRCodePayload)
|
||||||
|
.font(.body.monospaced())
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(16)
|
||||||
|
.frame(minHeight: compactLayout ? 130 : 150)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
|
||||||
|
if model.isAuthenticating {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Binding this device to your account…")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if compactLayout {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
primaryButtons
|
||||||
|
secondaryButtons
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
primaryButtons
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
secondaryButtons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var primaryButtons: some View {
|
||||||
|
Button {
|
||||||
|
model.isScannerPresented = true
|
||||||
|
} label: {
|
||||||
|
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await model.signInWithManualCode()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if model.isAuthenticating {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Label("Bind With Payload", systemImage: "arrow.right.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(model.isAuthenticating)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var secondaryButtons: some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await model.signInWithSuggestedCode()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Use Preview QR", systemImage: "wand.and.stars")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TrustFootprintCard: View {
|
||||||
|
@ObservedObject var model: AppViewModel
|
||||||
|
let compactLayout: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if compactLayout {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
trustFacts
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
trustFacts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Preview Pairing Payload")
|
||||||
|
.font(.headline)
|
||||||
|
Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload)
|
||||||
|
.font(.footnote.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var trustFacts: some View {
|
||||||
|
TrustFactCard(
|
||||||
|
icon: "person.badge.key.fill",
|
||||||
|
title: "Account Binding",
|
||||||
|
message: "This device binds to your idp.global account and becomes your place for approvals and alerts."
|
||||||
|
)
|
||||||
|
TrustFactCard(
|
||||||
|
icon: "person.2.badge.gearshape.fill",
|
||||||
|
title: "Built by foss.global",
|
||||||
|
message: "foss.global is the open-source collective behind idp.global and the current preview environment."
|
||||||
|
)
|
||||||
|
TrustFactCard(
|
||||||
|
icon: "bolt.badge.clock",
|
||||||
|
title: "Preview Backend",
|
||||||
|
message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LoginCard<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let content: () -> Content
|
||||||
|
|
||||||
|
init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) {
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
Text(subtitle)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HeroTag: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TrustFactCard: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(loginAccent)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
359
Sources/Features/Auth/QRScannerView.swift
Normal file
359
Sources/Features/Auth/QRScannerView.swift
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct QRScannerSheet: View {
|
||||||
|
let seededPayload: String
|
||||||
|
let onCodeScanned: (String) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var manualFallback = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("Use the camera to scan the QR code shown by the web portal. If you’re on a simulator or desktop without a camera, the seeded payload works as a mock fallback.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
||||||
|
.frame(minHeight: 340)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Fallback Pairing Payload")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
TextEditor(text: $manualFallback)
|
||||||
|
.font(.body.monospaced())
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(14)
|
||||||
|
.frame(minHeight: 120)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Label("Use Fallback Payload", systemImage: "arrow.up.forward.square")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
manualFallback = seededPayload
|
||||||
|
} label: {
|
||||||
|
Label("Use Seeded Mock", systemImage: "wand.and.rays")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
.navigationTitle("Scan QR Code")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
manualFallback = seededPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LiveQRScannerView: View {
|
||||||
|
let onCodeScanned: (String) -> Void
|
||||||
|
|
||||||
|
@StateObject private var scanner = QRScannerViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
Group {
|
||||||
|
if scanner.isPreviewAvailable {
|
||||||
|
ScannerPreview(session: scanner.captureSession)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||||
|
.fill(Color.black.opacity(0.86))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Image(systemName: "video.slash.fill")
|
||||||
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("Live camera preview unavailable")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(scanner.statusMessage)
|
||||||
|
.foregroundStyle(.white.opacity(0.78))
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Camera Scanner")
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(scanner.statusMessage)
|
||||||
|
.foregroundStyle(.white.opacity(0.84))
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
|
||||||
|
ScanFrameOverlay()
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
scanner.onCodeScanned = { payload in
|
||||||
|
onCodeScanned(payload)
|
||||||
|
}
|
||||||
|
await scanner.start()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
scanner.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ScanFrameOverlay: View {
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let size = min(geometry.size.width, geometry.size.height) * 0.5
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
@Published var isPreviewAvailable = false
|
||||||
|
@Published var statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||||
|
|
||||||
|
let captureSession = AVCaptureSession()
|
||||||
|
|
||||||
|
var onCodeScanned: ((String) -> Void)?
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "global.idp.qrscanner")
|
||||||
|
private var isConfigured = false
|
||||||
|
private var hasDeliveredCode = false
|
||||||
|
|
||||||
|
func start() async {
|
||||||
|
#if os(iOS) && targetEnvironment(simulator)
|
||||||
|
await MainActor.run {
|
||||||
|
isPreviewAvailable = false
|
||||||
|
statusMessage = "The iOS simulator has no live camera feed. Use the seeded payload below."
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !(os(iOS) && targetEnvironment(simulator))
|
||||||
|
let authorization = AVCaptureDevice.authorizationStatus(for: .video)
|
||||||
|
switch authorization {
|
||||||
|
case .authorized:
|
||||||
|
await configureIfNeeded()
|
||||||
|
startRunning()
|
||||||
|
case .notDetermined:
|
||||||
|
let granted = await requestCameraAccess()
|
||||||
|
await MainActor.run {
|
||||||
|
self.statusMessage = granted
|
||||||
|
? "Point the camera at the QR code from the idp.global web portal."
|
||||||
|
: "Camera access was denied. Use the fallback payload below."
|
||||||
|
}
|
||||||
|
guard granted else { return }
|
||||||
|
await configureIfNeeded()
|
||||||
|
startRunning()
|
||||||
|
case .denied, .restricted:
|
||||||
|
await MainActor.run {
|
||||||
|
isPreviewAvailable = false
|
||||||
|
statusMessage = "Camera access is unavailable. Use the fallback payload below."
|
||||||
|
}
|
||||||
|
@unknown default:
|
||||||
|
await MainActor.run {
|
||||||
|
isPreviewAvailable = false
|
||||||
|
statusMessage = "Camera access could not be initialized on this device."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
queue.async {
|
||||||
|
if self.captureSession.isRunning {
|
||||||
|
self.captureSession.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataOutput(
|
||||||
|
_ output: AVCaptureMetadataOutput,
|
||||||
|
didOutput metadataObjects: [AVMetadataObject],
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
guard !hasDeliveredCode,
|
||||||
|
let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||||
|
readable.type == .qr,
|
||||||
|
let payload = readable.stringValue else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDeliveredCode = true
|
||||||
|
stop()
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [onCodeScanned] in
|
||||||
|
onCodeScanned?(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestCameraAccess() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureIfNeeded() async {
|
||||||
|
guard !isConfigured else {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isPreviewAvailable = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||||
|
queue.async {
|
||||||
|
self.captureSession.beginConfiguration()
|
||||||
|
defer {
|
||||||
|
self.captureSession.commitConfiguration()
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
|
self.captureSession.canAddInput(input) else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isPreviewAvailable = false
|
||||||
|
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.captureSession.addInput(input)
|
||||||
|
|
||||||
|
let output = AVCaptureMetadataOutput()
|
||||||
|
guard self.captureSession.canAddOutput(output) else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isPreviewAvailable = false
|
||||||
|
self.statusMessage = "Unable to configure QR metadata scanning on this device."
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.captureSession.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||||
|
output.metadataObjectTypes = [.qr]
|
||||||
|
self.isConfigured = true
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isPreviewAvailable = true
|
||||||
|
self.statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRunning() {
|
||||||
|
queue.async {
|
||||||
|
guard !self.captureSession.isRunning else { return }
|
||||||
|
self.hasDeliveredCode = false
|
||||||
|
self.captureSession.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension QRScannerViewModel: @unchecked Sendable {}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private struct ScannerPreview: UIViewRepresentable {
|
||||||
|
let session: AVCaptureSession
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> ScannerPreviewUIView {
|
||||||
|
let view = ScannerPreviewUIView()
|
||||||
|
view.previewLayer.session = session
|
||||||
|
view.previewLayer.videoGravity = .resizeAspectFill
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) {
|
||||||
|
uiView.previewLayer.session = session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ScannerPreviewUIView: UIView {
|
||||||
|
override class var layerClass: AnyClass {
|
||||||
|
AVCaptureVideoPreviewLayer.self
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||||
|
layer as! AVCaptureVideoPreviewLayer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elseif os(macOS)
|
||||||
|
private struct ScannerPreview: NSViewRepresentable {
|
||||||
|
let session: AVCaptureSession
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> ScannerPreviewNSView {
|
||||||
|
let view = ScannerPreviewNSView()
|
||||||
|
view.attach(session: session)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) {
|
||||||
|
nsView.attach(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ScannerPreviewNSView: NSView {
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
wantsLayer = true
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
wantsLayer = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func attach(session: AVCaptureSession) {
|
||||||
|
let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
layer.session = session
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
self.layer = layer
|
||||||
|
previewLayer = layer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
2918
Sources/Features/Home/HomeRootView.swift
Normal file
2918
Sources/Features/Home/HomeRootView.swift
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user