Refocus app around identity proof flows
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.DS_Store
|
||||
agentcomms/
|
||||
build/
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
|
||||
36
Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "iphone-20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },
|
||||
{ "filename" : "iphone-20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },
|
||||
{ "filename" : "iphone-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
|
||||
{ "filename" : "iphone-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
|
||||
{ "filename" : "iphone-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
|
||||
{ "filename" : "iphone-40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },
|
||||
{ "filename" : "iphone-60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },
|
||||
{ "filename" : "iphone-60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },
|
||||
{ "filename" : "ipad-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
|
||||
{ "filename" : "ipad-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
|
||||
{ "filename" : "ipad-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
|
||||
{ "filename" : "ipad-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
|
||||
{ "filename" : "ipad-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
|
||||
{ "filename" : "ipad-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
|
||||
{ "filename" : "ipad-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
|
||||
{ "filename" : "ipad-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
||||
{ "filename" : "ipad-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
||||
{ "filename" : "ios-marketing-1024@1x.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" },
|
||||
{ "filename" : "mac-16@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
|
||||
{ "filename" : "mac-16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
|
||||
{ "filename" : "mac-32@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
||||
{ "filename" : "mac-32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
|
||||
{ "filename" : "mac-128@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
|
||||
{ "filename" : "mac-128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
|
||||
{ "filename" : "mac-256@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
|
||||
{ "filename" : "mac-256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
|
||||
{ "filename" : "mac-512@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
||||
{ "filename" : "mac-512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png
Normal file
|
After Width: | Height: | Size: 1006 B |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-128@1x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-128@2x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-16@1x.png
Normal file
|
After Width: | Height: | Size: 776 B |
BIN
Assets.xcassets/AppIcon.appiconset/mac-16@2x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-256@1x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-256@2x.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-32@1x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-32@2x.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-512@1x.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
Assets.xcassets/AppIcon.appiconset/mac-512@2x.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
6
Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
10
IDPGlobal.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?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>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>NDEF</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -15,8 +15,43 @@
|
||||
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 */; };
|
||||
B10000000000000000000009 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* AppViewModel.swift */; };
|
||||
B1000000000000000000000A /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* AppModels.swift */; };
|
||||
B1000000000000000000000B /* MockIDPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* MockIDPService.swift */; };
|
||||
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* NotificationCoordinator.swift */; };
|
||||
B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000B /* IDPGlobalWatchApp.swift */; };
|
||||
B1000000000000000000000E /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000C /* WatchRootView.swift */; };
|
||||
B1000000000000000000000F /* IDPGlobalWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* IDPGlobalWatch.app */; platformFilter = ios; };
|
||||
B10000000000000000000010 /* NFCPairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000D /* NFCPairingView.swift */; };
|
||||
B10000000000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* Assets.xcassets */; };
|
||||
B10000000000000000000012 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
|
||||
B10000000000000000000013 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
B90000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = B60000000000000000000001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = B50000000000000000000002;
|
||||
remoteInfo = IDPGlobalWatch;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
B30000000000000000000004 /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
B1000000000000000000000F /* IDPGlobalWatch.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase 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>"; };
|
||||
@@ -27,6 +62,12 @@
|
||||
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; };
|
||||
B2000000000000000000000A /* IDPGlobalWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IDPGlobalWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B2000000000000000000000B /* IDPGlobalWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDPGlobalWatchApp.swift; sourceTree = "<group>"; };
|
||||
B2000000000000000000000C /* WatchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = "<group>"; };
|
||||
B2000000000000000000000D /* NFCPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCPairingView.swift; sourceTree = "<group>"; };
|
||||
B2000000000000000000000E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B2000000000000000000000F /* AppComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppComponents.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -37,6 +78,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000005 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -51,7 +99,9 @@
|
||||
B40000000000000000000002 /* IDPGlobal */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2000000000000000000000E /* Assets.xcassets */,
|
||||
B40000000000000000000003 /* Sources */,
|
||||
B4000000000000000000000C /* WatchApp */,
|
||||
);
|
||||
name = IDPGlobal;
|
||||
sourceTree = "<group>";
|
||||
@@ -69,6 +119,7 @@
|
||||
B40000000000000000000004 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2000000000000000000000F /* AppComponents.swift */,
|
||||
B20000000000000000000001 /* IDPGlobalApp.swift */,
|
||||
B20000000000000000000002 /* AppViewModel.swift */,
|
||||
);
|
||||
@@ -114,6 +165,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000009 /* IDPGlobal.app */,
|
||||
B2000000000000000000000A /* IDPGlobalWatch.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -122,6 +174,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000006 /* LoginRootView.swift */,
|
||||
B2000000000000000000000D /* NFCPairingView.swift */,
|
||||
B20000000000000000000007 /* QRScannerView.swift */,
|
||||
);
|
||||
path = Auth;
|
||||
@@ -135,6 +188,31 @@
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000C /* WatchApp */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4000000000000000000000D /* App */,
|
||||
B4000000000000000000000E /* Features */,
|
||||
);
|
||||
path = WatchApp;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000D /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2000000000000000000000B /* IDPGlobalWatchApp.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000E /* Features */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2000000000000000000000C /* WatchRootView.swift */,
|
||||
);
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -145,14 +223,33 @@
|
||||
B30000000000000000000002 /* Sources */,
|
||||
B30000000000000000000001 /* Frameworks */,
|
||||
B30000000000000000000003 /* Resources */,
|
||||
B30000000000000000000004 /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B90000000000000000000002 /* PBXTargetDependency */,
|
||||
);
|
||||
name = IDPGlobal;
|
||||
productName = IDPGlobal;
|
||||
productReference = B20000000000000000000009 /* IDPGlobal.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B50000000000000000000002 /* IDPGlobalWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B70000000000000000000003 /* Build configuration list for PBXNativeTarget "IDPGlobalWatch" */;
|
||||
buildPhases = (
|
||||
B30000000000000000000007 /* Sources */,
|
||||
B30000000000000000000005 /* Frameworks */,
|
||||
B30000000000000000000006 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = IDPGlobal;
|
||||
productName = IDPGlobal;
|
||||
productReference = B20000000000000000000009 /* IDPGlobal.app */;
|
||||
name = IDPGlobalWatch;
|
||||
productName = IDPGlobalWatch;
|
||||
productReference = B2000000000000000000000A /* IDPGlobalWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -168,6 +265,9 @@
|
||||
B50000000000000000000001 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
B50000000000000000000002 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
|
||||
@@ -184,12 +284,21 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
B50000000000000000000001 /* IDPGlobal */,
|
||||
B50000000000000000000002 /* IDPGlobalWatch */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
B30000000000000000000003 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000011 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000006 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -203,19 +312,44 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000012 /* AppComponents.swift in Sources */,
|
||||
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 */,
|
||||
B10000000000000000000010 /* NFCPairingView.swift in Sources */,
|
||||
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */,
|
||||
B10000000000000000000003 /* AppModels.swift in Sources */,
|
||||
B10000000000000000000007 /* QRScannerView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000007 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000013 /* AppComponents.swift in Sources */,
|
||||
B10000000000000000000009 /* AppViewModel.swift in Sources */,
|
||||
B1000000000000000000000A /* AppModels.swift in Sources */,
|
||||
B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */,
|
||||
B1000000000000000000000B /* MockIDPService.swift in Sources */,
|
||||
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */,
|
||||
B1000000000000000000000E /* WatchRootView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B90000000000000000000002 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilter = ios;
|
||||
target = B50000000000000000000002 /* IDPGlobalWatch */;
|
||||
targetProxy = B90000000000000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
B80000000000000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -275,7 +409,9 @@
|
||||
B80000000000000000000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = IDPGlobal.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
@@ -284,6 +420,8 @@
|
||||
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_NSLocationWhenInUseUsageDescription = "Attach a signed GPS position to NFC authentication before binding this device.";
|
||||
INFOPLIST_KEY_NFCReaderUsageDescription = "Read an idp.global pairing tag to bind this device.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -305,7 +443,9 @@
|
||||
B80000000000000000000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = IDPGlobal.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
@@ -314,6 +454,8 @@
|
||||
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_NSLocationWhenInUseUsageDescription = "Attach a signed GPS position to NFC authentication before binding this device.";
|
||||
INFOPLIST_KEY_NFCReaderUsageDescription = "Read an idp.global pairing tag to bind this device.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -332,6 +474,64 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B80000000000000000000005 /* 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 Watch";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBSERVATION_ENABLED = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B80000000000000000000006 /* 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 Watch";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBSERVATION_ENABLED = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -353,6 +553,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B70000000000000000000003 /* Build configuration list for PBXNativeTarget "IDPGlobalWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B80000000000000000000005 /* Debug */,
|
||||
B80000000000000000000006 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = B60000000000000000000001 /* Project object */;
|
||||
|
||||
103
README.md
@@ -1,95 +1,40 @@
|
||||
# idp.global Swift App
|
||||
|
||||
Multiplatform SwiftUI companion app for `idp.global` across iPhone, iPad, and Mac.
|
||||
Multiplatform SwiftUI scaffold for the personal `idp.global` companion app on iPhone, iPad, Mac, and Apple Watch.
|
||||
|
||||
The current build is a polished preview backed by a mock service layer. It already walks through the core product flow:
|
||||
## Included in this first pass
|
||||
|
||||
- bind a device to an account with a QR payload
|
||||
- review and approve identity or access requests
|
||||
- track recent security and system events
|
||||
- manage notification permissions and send a local test alert
|
||||
- QR and NFC-based device pairing flows with a seeded preview payload fallback
|
||||
- NFC authentication now attaches a signed GPS position on supported iPhone hardware
|
||||
- Mocked approval inbox for accepting or rejecting identity requests
|
||||
- Notification center with local notification permission flow and a test notification trigger
|
||||
- Apple Watch companion target with a compact approval-first dashboard and request detail flow
|
||||
- Shared app state and mock backend boundary so a real API can be connected later
|
||||
|
||||
## Current Product Surface
|
||||
## Open the project
|
||||
|
||||
After pairing, the app opens into a passport-style dashboard with four sections:
|
||||
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
|
||||
3. Build the `IDPGlobalWatch` scheme for an Apple Watch simulator when you want to verify the companion experience.
|
||||
|
||||
- `Passport`: digital identity summary, trust context, and quick actions
|
||||
- `Requests`: approval queue with elevated-risk guidance and inline review
|
||||
- `Activity`: timeline of pairing, approval, and system events
|
||||
- `Account`: member profile, trusted-device context, and recovery summary
|
||||
## Mock QR payload
|
||||
|
||||
The layout adapts by platform:
|
||||
|
||||
- iPhone uses a compact tab-based container
|
||||
- iPad and Mac use a split-view workspace with richer side-by-side review
|
||||
|
||||
## Pairing Flow
|
||||
|
||||
The sign-in flow supports:
|
||||
|
||||
- live QR scanning through the camera
|
||||
- manual payload paste for testing
|
||||
- a seeded preview payload while the real backend is still being wired up
|
||||
|
||||
Seeded payload on first launch:
|
||||
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`
|
||||
|
||||
## Mocked Preview Behavior
|
||||
You can paste it manually, scan it as a QR code, or use the preview pairing action while the backend is still mocked.
|
||||
|
||||
The app currently runs against `MockIDPService`, which seeds:
|
||||
For NFC authentication, the app reads the pairing payload from the tag, captures the current device location, signs that GPS position, and submits both together.
|
||||
|
||||
- a paired member profile
|
||||
- pending and handled approval requests
|
||||
- recent notifications and security events
|
||||
- simulated incoming requests from the toolbar
|
||||
## Next integration step
|
||||
|
||||
This keeps the UI realistic while preserving a clean integration seam for the live backend later.
|
||||
Replace `MockIDPService` with a live service that:
|
||||
|
||||
## Open And Run
|
||||
|
||||
1. Open `IDPGlobal.xcodeproj` in Xcode.
|
||||
2. Build and run the `IDPGlobal` scheme on:
|
||||
- `My Mac`
|
||||
- an iPhone simulator
|
||||
- an iPad simulator
|
||||
|
||||
You can also build from the command line:
|
||||
|
||||
```bash
|
||||
xcodebuild -project IDPGlobal.xcodeproj -scheme IDPGlobal -configuration Debug -destination 'platform=macOS' build
|
||||
```
|
||||
|
||||
## Useful Preview Launch Arguments
|
||||
|
||||
These launch arguments are already supported by the app model:
|
||||
|
||||
- `--mock-auto-pair`: automatically pair with the seeded preview payload on launch
|
||||
- `--mock-section=overview`
|
||||
- `--mock-section=requests`
|
||||
- `--mock-section=activity`
|
||||
- `--mock-section=account`
|
||||
- `--mock-section=notifications`: opens the activity timeline using a notification-friendly alias
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
--mock-auto-pair --mock-section=requests
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `Sources/App`: app entry point and shared state in `AppViewModel`
|
||||
- `Sources/Features/Auth`: first-run pairing flow and QR scanner UI
|
||||
- `Sources/Features/Home`: passport dashboard, requests, activity, notifications, and account surfaces
|
||||
- `Sources/Core/Models`: app-facing domain models
|
||||
- `Sources/Core/Services`: mock backend boundary and local notification coordination
|
||||
|
||||
## Next Integration Step
|
||||
|
||||
Replace `MockIDPService` with a live implementation that:
|
||||
|
||||
- exchanges the QR payload for a real session
|
||||
- loads profile, request, and activity data from the backend
|
||||
- exchanges the pairing payload and signed NFC location proof for a session token
|
||||
- loads approval requests and notifications from the backend
|
||||
- posts approval decisions back to `idp.global`
|
||||
- syncs notification state with server-side events
|
||||
- syncs session and request state between iPhone and Apple Watch, likely through a shared backend session or WatchConnectivity bridge
|
||||
|
||||
409
Sources/App/AppComponents.swift
Normal file
@@ -0,0 +1,409 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppTheme {
|
||||
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
||||
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
|
||||
static let border = Color.black.opacity(0.08)
|
||||
static let shadow = Color.black.opacity(0.05)
|
||||
static let cardFill = Color.white.opacity(0.96)
|
||||
static let mutedFill = Color(red: 0.972, green: 0.976, blue: 0.970)
|
||||
}
|
||||
|
||||
enum AppLayout {
|
||||
static let compactHorizontalPadding: CGFloat = 16
|
||||
static let regularHorizontalPadding: CGFloat = 28
|
||||
static let compactVerticalPadding: CGFloat = 18
|
||||
static let regularVerticalPadding: CGFloat = 28
|
||||
static let compactContentWidth: CGFloat = 720
|
||||
static let regularContentWidth: CGFloat = 920
|
||||
static let cardRadius: CGFloat = 24
|
||||
static let largeCardRadius: CGFloat = 30
|
||||
static let compactSectionPadding: CGFloat = 18
|
||||
static let regularSectionPadding: CGFloat = 24
|
||||
static let compactSectionSpacing: CGFloat = 18
|
||||
static let regularSectionSpacing: CGFloat = 24
|
||||
static let compactBottomDockPadding: CGFloat = 120
|
||||
static let regularBottomPadding: CGFloat = 56
|
||||
|
||||
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
|
||||
}
|
||||
|
||||
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactVerticalPadding : regularVerticalPadding
|
||||
}
|
||||
|
||||
static func contentWidth(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactContentWidth : regularContentWidth
|
||||
}
|
||||
|
||||
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionPadding : regularSectionPadding
|
||||
}
|
||||
|
||||
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionSpacing : regularSectionSpacing
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
|
||||
background(
|
||||
fill,
|
||||
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.975, green: 0.978, blue: 0.972),
|
||||
Color.white
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.02))
|
||||
.frame(height: 160)
|
||||
.blur(radius: 60)
|
||||
.offset(y: -90)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct AppScrollScreen<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
var bottomPadding: CGFloat? = nil
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
bottomPadding: CGFloat? = nil,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.bottomPadding = bottomPadding
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
|
||||
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
|
||||
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
|
||||
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppPanel<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
let radius: CGFloat
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
radius: CGFloat = AppLayout.cardRadius,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.radius = radius
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
content()
|
||||
}
|
||||
.padding(AppLayout.sectionPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.appSurface(radius: radius)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBadge: View {
|
||||
let title: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(tone.opacity(0.10), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct AppSectionCard<Content: View>: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let compactLayout: Bool
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
compactLayout: Bool,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.compactLayout = compactLayout
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
AppSectionTitle(title: title, subtitle: subtitle)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppSectionTitle: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotice: View {
|
||||
let message: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(tone)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(tone.opacity(0.08), in: Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppStatusTag: View {
|
||||
let title: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(tone.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(tone)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppKeyValue: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var monospaced: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(monospaced ? .subheadline.monospaced() : .subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppMetric: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppTextSurface: View {
|
||||
let text: String
|
||||
var monospaced: Bool = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
#if os(watchOS)
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
#else
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AppTextEditorField: View {
|
||||
@Binding var text: String
|
||||
var minHeight: CGFloat = 120
|
||||
var monospaced: Bool = true
|
||||
|
||||
var body: some View {
|
||||
editor
|
||||
.frame(minHeight: minHeight)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var editor: some View {
|
||||
#if os(watchOS)
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
#else
|
||||
TextEditor(text: $text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.autocorrectionDisabled()
|
||||
.padding(14)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AppActionRow: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let systemImage: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppActionTile: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
var tone: Color = AppTheme.accent
|
||||
var isBusy: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .center) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tone.opacity(0.10))
|
||||
.frame(width: 38, height: 38)
|
||||
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
.tint(tone)
|
||||
} else {
|
||||
Image(systemName: systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
|
||||
.appSurface(radius: 22)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppViewModel: ObservableObject {
|
||||
@Published var suggestedQRCodePayload = ""
|
||||
@Published var manualQRCodePayload = ""
|
||||
@Published var suggestedPairingPayload = ""
|
||||
@Published var manualPairingPayload = ""
|
||||
@Published var session: AuthSession?
|
||||
@Published var profile: MemberProfile?
|
||||
@Published var requests: [ApprovalRequest] = []
|
||||
@@ -13,11 +13,11 @@ final class AppViewModel: ObservableObject {
|
||||
@Published var selectedSection: AppSection = .overview
|
||||
@Published var isBootstrapping = false
|
||||
@Published var isAuthenticating = false
|
||||
@Published var isIdentifying = 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
|
||||
@@ -84,13 +84,13 @@ final class AppViewModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let bootstrap = try await service.bootstrap()
|
||||
suggestedQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||
manualQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
||||
manualPairingPayload = bootstrap.suggestedPairingPayload
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
|
||||
if launchArguments.contains("--mock-auto-pair"),
|
||||
session == nil {
|
||||
await signIn(with: bootstrap.suggestedQRCodePayload)
|
||||
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
|
||||
|
||||
if let preferredLaunchSection {
|
||||
selectedSection = preferredLaunchSection
|
||||
@@ -101,32 +101,52 @@ final class AppViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func signInWithManualCode() async {
|
||||
await signIn(with: manualQRCodePayload)
|
||||
func signInWithManualPayload() async {
|
||||
await signIn(with: manualPairingPayload, transport: .manual)
|
||||
}
|
||||
|
||||
func signInWithSuggestedCode() async {
|
||||
manualQRCodePayload = suggestedQRCodePayload
|
||||
await signIn(with: suggestedQRCodePayload)
|
||||
func signInWithSuggestedPayload() async {
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
await signIn(with: suggestedPairingPayload, transport: .preview)
|
||||
}
|
||||
|
||||
func signIn(with payload: String) async {
|
||||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
func signIn(
|
||||
with payload: String,
|
||||
transport: PairingTransport = .manual,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) async {
|
||||
await signIn(
|
||||
with: PairingAuthenticationRequest(
|
||||
pairingPayload: payload,
|
||||
transport: transport,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(with request: PairingAuthenticationRequest) async {
|
||||
let trimmed = request.pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Paste or scan a QR payload first."
|
||||
errorMessage = "Paste or scan a pairing payload first."
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedRequest = PairingAuthenticationRequest(
|
||||
pairingPayload: trimmed,
|
||||
transport: request.transport,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
|
||||
isAuthenticating = true
|
||||
defer { isAuthenticating = false }
|
||||
|
||||
do {
|
||||
let result = try await service.signIn(withQRCode: trimmed)
|
||||
let result = try await service.signIn(with: normalizedRequest)
|
||||
session = result.session
|
||||
apply(snapshot: result.snapshot)
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
selectedSection = .overview
|
||||
bannerMessage = "Paired with \(result.session.deviceName)."
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
@@ -135,6 +155,60 @@ final class AppViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func identifyWithNFC(_ request: PairingAuthenticationRequest) async {
|
||||
guard session != nil else {
|
||||
errorMessage = "Set up this passport before proving your identity with NFC."
|
||||
return
|
||||
}
|
||||
|
||||
await submitIdentityProof(
|
||||
payload: request.pairingPayload,
|
||||
transport: .nfc,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
}
|
||||
|
||||
func identifyWithPayload(_ payload: String, transport: PairingTransport = .qr) async {
|
||||
guard session != nil else {
|
||||
errorMessage = "Set up this passport before proving your identity."
|
||||
return
|
||||
}
|
||||
|
||||
await submitIdentityProof(payload: payload, transport: transport)
|
||||
}
|
||||
|
||||
private func submitIdentityProof(
|
||||
payload: String,
|
||||
transport: PairingTransport,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) async {
|
||||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "The provided idp.global payload was empty."
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedRequest = PairingAuthenticationRequest(
|
||||
pairingPayload: trimmed,
|
||||
transport: transport,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
|
||||
isIdentifying = true
|
||||
defer { isIdentifying = false }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.identify(with: normalizedRequest)
|
||||
apply(snapshot: snapshot)
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
} catch {
|
||||
errorMessage = "Unable to complete identity proof."
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDashboard() async {
|
||||
guard session != nil else { return }
|
||||
|
||||
@@ -144,6 +218,7 @@ final class AppViewModel: ObservableObject {
|
||||
do {
|
||||
let snapshot = try await service.refreshDashboard()
|
||||
apply(snapshot: snapshot)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to refresh the dashboard."
|
||||
}
|
||||
@@ -164,18 +239,16 @@ final class AppViewModel: ObservableObject {
|
||||
let snapshot = try await service.simulateIncomingRequest()
|
||||
apply(snapshot: snapshot)
|
||||
selectedSection = .requests
|
||||
bannerMessage = "A new mock approval request arrived."
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to seed a new request right now."
|
||||
errorMessage = "Unable to create a mock identity check right now."
|
||||
}
|
||||
}
|
||||
|
||||
func requestNotificationAccess() async {
|
||||
do {
|
||||
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
||||
if notificationPermission == .allowed || notificationPermission == .provisional {
|
||||
bannerMessage = "Notifications are ready on this device."
|
||||
}
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update notification permission."
|
||||
}
|
||||
@@ -184,11 +257,11 @@ final class AppViewModel: ObservableObject {
|
||||
func sendTestNotification() async {
|
||||
do {
|
||||
try await notificationCoordinator.scheduleTestNotification(
|
||||
title: "idp.global approval pending",
|
||||
body: "A mock request is waiting for approval in the app."
|
||||
title: "idp.global identity proof requested",
|
||||
body: "A mock identity proof request is waiting in the app."
|
||||
)
|
||||
bannerMessage = "A local test notification will appear in a few seconds."
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to schedule a test notification."
|
||||
}
|
||||
@@ -198,6 +271,7 @@ final class AppViewModel: ObservableObject {
|
||||
do {
|
||||
let snapshot = try await service.markNotificationRead(id: notification.id)
|
||||
apply(snapshot: snapshot)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the notification."
|
||||
}
|
||||
@@ -209,8 +283,8 @@ final class AppViewModel: ObservableObject {
|
||||
requests = []
|
||||
notifications = []
|
||||
selectedSection = .overview
|
||||
bannerMessage = nil
|
||||
manualQRCodePayload = suggestedQRCodePayload
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
||||
@@ -224,9 +298,9 @@ final class AppViewModel: ObservableObject {
|
||||
? 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)."
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the request."
|
||||
errorMessage = "Unable to update the identity check."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ struct IDPGlobalApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.tint(Color(red: 0.12, green: 0.40, blue: 0.31))
|
||||
.tint(AppTheme.accent)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
@@ -47,17 +47,8 @@ private struct RootView: View {
|
||||
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()
|
||||
)
|
||||
.background {
|
||||
AppBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||
@@ -58,17 +59,119 @@ enum NotificationPermissionState: String, CaseIterable, Identifiable {
|
||||
case .unknown:
|
||||
"The app has not asked for notification delivery yet."
|
||||
case .allowed:
|
||||
"Alerts can break through immediately when a request arrives."
|
||||
"Identity proof alerts can break through immediately when a check arrives."
|
||||
case .provisional:
|
||||
"Notifications can be delivered quietly until the user promotes them."
|
||||
"Identity proof alerts can be delivered quietly until the user promotes them."
|
||||
case .denied:
|
||||
"Approval events stay in-app until the user re-enables notifications."
|
||||
"Identity proof events stay in-app until the user re-enables notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BootstrapContext {
|
||||
let suggestedQRCodePayload: String
|
||||
let suggestedPairingPayload: String
|
||||
}
|
||||
|
||||
enum PairingTransport: String, Hashable {
|
||||
case qr
|
||||
case nfc
|
||||
case manual
|
||||
case preview
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .qr:
|
||||
"QR"
|
||||
case .nfc:
|
||||
"NFC"
|
||||
case .manual:
|
||||
"Manual"
|
||||
case .preview:
|
||||
"Preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingAuthenticationRequest: Hashable {
|
||||
let pairingPayload: String
|
||||
let transport: PairingTransport
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
}
|
||||
|
||||
struct SignedGPSPosition: Hashable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let horizontalAccuracyMeters: Double
|
||||
let capturedAt: Date
|
||||
let signatureBase64: String
|
||||
let publicKeyBase64: String
|
||||
|
||||
init(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
horizontalAccuracyMeters: Double,
|
||||
capturedAt: Date,
|
||||
signatureBase64: String = "",
|
||||
publicKeyBase64: String = ""
|
||||
) {
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.horizontalAccuracyMeters = horizontalAccuracyMeters
|
||||
self.capturedAt = capturedAt
|
||||
self.signatureBase64 = signatureBase64
|
||||
self.publicKeyBase64 = publicKeyBase64
|
||||
}
|
||||
|
||||
var coordinateSummary: String {
|
||||
"\(Self.normalized(latitude, precision: 5)), \(Self.normalized(longitude, precision: 5))"
|
||||
}
|
||||
|
||||
var accuracySummary: String {
|
||||
"±\(Int(horizontalAccuracyMeters.rounded())) m"
|
||||
}
|
||||
|
||||
func signingPayload(for pairingPayload: String) -> Data {
|
||||
let lines = [
|
||||
"payload=\(pairingPayload)",
|
||||
"latitude=\(Self.normalized(latitude, precision: 6))",
|
||||
"longitude=\(Self.normalized(longitude, precision: 6))",
|
||||
"accuracy=\(Self.normalized(horizontalAccuracyMeters, precision: 2))",
|
||||
"captured_at=\(Self.timestampFormatter.string(from: capturedAt))"
|
||||
]
|
||||
return Data(lines.joined(separator: "\n").utf8)
|
||||
}
|
||||
|
||||
func verified(for pairingPayload: String) -> Bool {
|
||||
guard let signatureData = Data(base64Encoded: signatureBase64),
|
||||
let publicKeyData = Data(base64Encoded: publicKeyBase64),
|
||||
let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyData),
|
||||
let signature = try? P256.Signing.ECDSASignature(derRepresentation: signatureData) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return publicKey.isValidSignature(signature, for: signingPayload(for: pairingPayload))
|
||||
}
|
||||
|
||||
func signed(signatureData: Data, publicKeyData: Data) -> SignedGPSPosition {
|
||||
SignedGPSPosition(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
horizontalAccuracyMeters: horizontalAccuracyMeters,
|
||||
capturedAt: capturedAt,
|
||||
signatureBase64: signatureData.base64EncodedString(),
|
||||
publicKeyBase64: publicKeyData.base64EncodedString()
|
||||
)
|
||||
}
|
||||
|
||||
private static let timestampFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static func normalized(_ value: Double, precision: Int) -> String {
|
||||
String(format: "%.\(precision)f", locale: Locale(identifier: "en_US_POSIX"), value)
|
||||
}
|
||||
}
|
||||
|
||||
struct DashboardSnapshot {
|
||||
@@ -114,6 +217,8 @@ struct AuthSession: Identifiable, Hashable {
|
||||
let pairedAt: Date
|
||||
let tokenPreview: String
|
||||
let pairingCode: String
|
||||
let pairingTransport: PairingTransport
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
@@ -121,7 +226,9 @@ struct AuthSession: Identifiable, Hashable {
|
||||
originHost: String,
|
||||
pairedAt: Date,
|
||||
tokenPreview: String,
|
||||
pairingCode: String
|
||||
pairingCode: String,
|
||||
pairingTransport: PairingTransport = .manual,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.deviceName = deviceName
|
||||
@@ -129,6 +236,8 @@ struct AuthSession: Identifiable, Hashable {
|
||||
self.pairedAt = pairedAt
|
||||
self.tokenPreview = tokenPreview
|
||||
self.pairingCode = pairingCode
|
||||
self.pairingTransport = pairingTransport
|
||||
self.signedGPSPosition = signedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,17 +248,17 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .signIn: "Sign-In"
|
||||
case .accessGrant: "Access Grant"
|
||||
case .elevatedAction: "Elevated Action"
|
||||
case .signIn: "Identity Check"
|
||||
case .accessGrant: "Strong Proof"
|
||||
case .elevatedAction: "Sensitive Proof"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .signIn: "qrcode.viewfinder"
|
||||
case .accessGrant: "key.fill"
|
||||
case .elevatedAction: "shield.lefthalf.filled"
|
||||
case .accessGrant: "person.badge.shield.checkmark.fill"
|
||||
case .elevatedAction: "shield.checkered"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,18 +277,18 @@ enum ApprovalRisk: String, Hashable {
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Routine access to profile or sign-in scopes."
|
||||
"A familiar identity proof for a normal sign-in or check."
|
||||
case .elevated:
|
||||
"Sensitive access that can sign, publish, or unlock privileged actions."
|
||||
"A higher-assurance identity proof for a sensitive check."
|
||||
}
|
||||
}
|
||||
|
||||
var guidance: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Review the origin and scope list, then approve if the session matches the device you expect."
|
||||
"Review the origin and continue only if it matches the proof you started."
|
||||
case .elevated:
|
||||
"Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving."
|
||||
"Only continue if you initiated this proof and trust the origin asking for it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,8 +301,8 @@ enum ApprovalStatus: String, Hashable {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pending: "Pending"
|
||||
case .approved: "Approved"
|
||||
case .rejected: "Rejected"
|
||||
case .approved: "Verified"
|
||||
case .rejected: "Declined"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,34 +350,34 @@ struct ApprovalRequest: Identifiable, Hashable {
|
||||
|
||||
var scopeSummary: String {
|
||||
if scopes.isEmpty {
|
||||
return "No scopes listed"
|
||||
return "No proof details listed"
|
||||
}
|
||||
|
||||
let suffix = scopes.count == 1 ? "" : "s"
|
||||
return "\(scopes.count) requested scope\(suffix)"
|
||||
return "\(scopes.count) proof detail\(suffix)"
|
||||
}
|
||||
|
||||
var trustHeadline: String {
|
||||
switch (kind, risk) {
|
||||
case (.signIn, .routine):
|
||||
"Low-friction sign-in request"
|
||||
"Standard identity proof"
|
||||
case (.signIn, .elevated):
|
||||
"Privileged sign-in request"
|
||||
"High-assurance sign-in proof"
|
||||
case (.accessGrant, _):
|
||||
"Token grant request"
|
||||
"Cross-device identity proof"
|
||||
case (.elevatedAction, _):
|
||||
"Sensitive action request"
|
||||
"Sensitive identity proof"
|
||||
}
|
||||
}
|
||||
|
||||
var trustDetail: String {
|
||||
switch kind {
|
||||
case .signIn:
|
||||
"This request usually creates or refreshes a session token for a browser, CLI, or device."
|
||||
"This request proves that the person at the browser, CLI, or device is really you."
|
||||
case .accessGrant:
|
||||
"This request issues scoped access for a service or automation that wants to act on your behalf."
|
||||
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
|
||||
case .elevatedAction:
|
||||
"This request performs a privileged action such as signing, publishing, or creating short-lived credentials."
|
||||
"This request asks for the highest confidence proof before continuing with a sensitive flow."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +389,7 @@ enum AppNotificationKind: String, Hashable {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .approval: "Approval"
|
||||
case .approval: "Proof"
|
||||
case .security: "Security"
|
||||
case .system: "System"
|
||||
}
|
||||
@@ -297,9 +406,9 @@ enum AppNotificationKind: String, Hashable {
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .approval:
|
||||
"Decision and approval activity"
|
||||
"Identity proof activity"
|
||||
case .security:
|
||||
"Pairing and security posture updates"
|
||||
"Passport and security posture updates"
|
||||
case .system:
|
||||
"Product and environment status messages"
|
||||
}
|
||||
@@ -332,15 +441,27 @@ struct AppNotification: Identifiable, Hashable {
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case invalidQRCode
|
||||
case invalidPairingPayload
|
||||
case missingSignedGPSPosition
|
||||
case invalidSignedGPSPosition
|
||||
case locationPermissionDenied
|
||||
case locationUnavailable
|
||||
case requestNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidQRCode:
|
||||
"That QR payload is not valid for idp.global sign-in."
|
||||
case .invalidPairingPayload:
|
||||
"That idp.global payload is not valid for this action."
|
||||
case .missingSignedGPSPosition:
|
||||
"Tap NFC requires a signed GPS position."
|
||||
case .invalidSignedGPSPosition:
|
||||
"The signed GPS position attached to this NFC proof could not be verified."
|
||||
case .locationPermissionDenied:
|
||||
"Location access is required so Tap NFC can attach a signed GPS position."
|
||||
case .locationUnavailable:
|
||||
"Unable to determine the current GPS position for Tap NFC."
|
||||
case .requestNotFound:
|
||||
"The selected request could not be found."
|
||||
"The selected identity check could not be found."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import Foundation
|
||||
|
||||
protocol IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot
|
||||
func refreshDashboard() async throws -> DashboardSnapshot
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
@@ -30,18 +31,19 @@ actor MockIDPService: IDPServicing {
|
||||
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"
|
||||
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult {
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
|
||||
try await Task.sleep(for: .milliseconds(260))
|
||||
|
||||
let session = try parseSession(from: payload)
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let session = try parseSession(from: request)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "New device paired",
|
||||
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
|
||||
title: "Passport activated",
|
||||
message: pairingMessage(for: session),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
@@ -55,6 +57,25 @@ actor MockIDPService: IDPServicing {
|
||||
)
|
||||
}
|
||||
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity proof completed",
|
||||
message: identificationMessage(for: context, signedGPSPosition: request.signedGPSPosition),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func refreshDashboard() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
return snapshot()
|
||||
@@ -70,8 +91,8 @@ actor MockIDPService: IDPServicing {
|
||||
requests[index].status = .approved
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request approved",
|
||||
message: "\(requests[index].title) was approved for \(requests[index].source).",
|
||||
title: "Identity verified",
|
||||
message: "\(requests[index].title) was completed for \(requests[index].source).",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -92,8 +113,8 @@ actor MockIDPService: IDPServicing {
|
||||
requests[index].status = .rejected
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request rejected",
|
||||
message: "\(requests[index].title) was rejected before token issuance.",
|
||||
title: "Identity proof declined",
|
||||
message: "\(requests[index].title) was declined before the session could continue.",
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
@@ -108,21 +129,21 @@ actor MockIDPService: IDPServicing {
|
||||
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",
|
||||
title: "Prove identity for web sign-in",
|
||||
subtitle: "A browser session is asking this passport to prove that it is really you.",
|
||||
source: "auth.idp.global",
|
||||
createdAt: .now,
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["proof:basic", "client:web", "method:qr"],
|
||||
status: .pending
|
||||
)
|
||||
|
||||
requests.insert(syntheticRequest, at: 0)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Fresh approval request",
|
||||
message: "A staging deployment is waiting for your approval.",
|
||||
title: "Fresh identity proof request",
|
||||
message: "A new relying party is waiting for your identity proof.",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -152,7 +173,33 @@ actor MockIDPService: IDPServicing {
|
||||
)
|
||||
}
|
||||
|
||||
private func parseSession(from payload: String) throws -> AuthSession {
|
||||
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
|
||||
if request.transport == .nfc,
|
||||
request.signedGPSPosition == nil {
|
||||
throw AppError.missingSignedGPSPosition
|
||||
}
|
||||
|
||||
if let signedGPSPosition = request.signedGPSPosition,
|
||||
!signedGPSPosition.verified(for: request.pairingPayload) {
|
||||
throw AppError.invalidSignedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
|
||||
return AuthSession(
|
||||
deviceName: context.deviceName,
|
||||
originHost: context.originHost,
|
||||
pairedAt: .now,
|
||||
tokenPreview: context.tokenPreview,
|
||||
pairingCode: request.pairingPayload,
|
||||
pairingTransport: request.transport,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
}
|
||||
|
||||
private func parsePayloadContext(from payload: String) throws -> PayloadContext {
|
||||
if let components = URLComponents(string: payload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
@@ -161,58 +208,88 @@ actor MockIDPService: IDPServicing {
|
||||
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(
|
||||
return PayloadContext(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(token.suffix(6)),
|
||||
pairingCode: payload
|
||||
tokenPreview: String(token.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
if payload.contains("token") || payload.contains("pair") {
|
||||
return AuthSession(
|
||||
deviceName: "Manual Pairing",
|
||||
return PayloadContext(
|
||||
deviceName: "Manual Session",
|
||||
originHost: "code.foss.global",
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(payload.suffix(6)),
|
||||
pairingCode: payload
|
||||
tokenPreview: String(payload.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidQRCode
|
||||
throw AppError.invalidPairingPayload
|
||||
}
|
||||
|
||||
private func pairingMessage(for session: AuthSession) -> String {
|
||||
let transportSummary: String
|
||||
switch session.pairingTransport {
|
||||
case .qr:
|
||||
transportSummary = "activated via QR"
|
||||
case .nfc:
|
||||
transportSummary = "activated via NFC with a signed GPS position"
|
||||
case .manual:
|
||||
transportSummary = "activated via manual payload"
|
||||
case .preview:
|
||||
transportSummary = "activated via preview payload"
|
||||
}
|
||||
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
|
||||
}
|
||||
|
||||
private func identificationMessage(for context: PayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
|
||||
if let signedGPSPosition {
|
||||
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
|
||||
}
|
||||
|
||||
private struct PayloadContext {
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let tokenPreview: String
|
||||
}
|
||||
|
||||
private static func seedRequests() -> [ApprovalRequest] {
|
||||
[
|
||||
ApprovalRequest(
|
||||
title: "Approve Safari sign-in",
|
||||
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
|
||||
title: "Prove identity for Safari sign-in",
|
||||
subtitle: "The portal wants this passport to prove that the browser session is really you.",
|
||||
source: "code.foss.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 12),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile", "groups:read"],
|
||||
scopes: ["proof:basic", "client:web", "origin:trusted"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Grant package publish access",
|
||||
subtitle: "The release bot is asking for a scoped publish token.",
|
||||
source: "registry.foss.global",
|
||||
title: "Prove identity for workstation unlock",
|
||||
subtitle: "Your secure workspace is asking for a stronger proof before it unlocks.",
|
||||
source: "berlin-mbp.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 42),
|
||||
kind: .accessGrant,
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["packages:write", "ttl:30m"],
|
||||
scopes: ["proof:high", "client:desktop", "presence:required"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Approve CLI login",
|
||||
subtitle: "A terminal session completed QR pairing earlier today.",
|
||||
title: "Prove identity for CLI session",
|
||||
subtitle: "The CLI session asked for proof earlier and was completed from this passport.",
|
||||
source: "cli.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 180),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile"],
|
||||
scopes: ["proof:basic", "client:cli"],
|
||||
status: .approved
|
||||
)
|
||||
]
|
||||
@@ -221,8 +298,8 @@ actor MockIDPService: IDPServicing {
|
||||
private static func seedNotifications() -> [AppNotification] {
|
||||
[
|
||||
AppNotification(
|
||||
title: "Two requests are waiting",
|
||||
message: "The queue includes one routine sign-in and one elevated access grant.",
|
||||
title: "Two identity checks are waiting",
|
||||
message: "One routine web proof and one stronger workstation proof are waiting for this passport.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 8),
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -235,8 +312,8 @@ actor MockIDPService: IDPServicing {
|
||||
isUnread: false
|
||||
),
|
||||
AppNotification(
|
||||
title: "Quiet hours active on mobile",
|
||||
message: "Routine notifications will be delivered silently until the morning.",
|
||||
title: "Passport quiet hours active",
|
||||
message: "Routine identity checks will be delivered silently until the morning.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 220),
|
||||
kind: .security,
|
||||
isUnread: false
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
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)
|
||||
private let loginAccent = AppTheme.accent
|
||||
|
||||
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)
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
||||
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
seededPayload: model.suggestedQRCodePayload,
|
||||
seededPayload: model.suggestedPairingPayload,
|
||||
title: "Scan linking QR",
|
||||
description: "Use the camera to scan the QR code from the web flow that activates this device as your passport.",
|
||||
navigationTitle: "Scan Linking QR",
|
||||
onCodeScanned: { payload in
|
||||
model.manualQRCodePayload = payload
|
||||
model.manualPairingPayload = payload
|
||||
Task {
|
||||
await model.signIn(with: payload)
|
||||
await model.signIn(with: payload, transport: .qr)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -44,51 +41,49 @@ private struct LoginHeroPanel: View {
|
||||
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
|
||||
)
|
||||
)
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Secure passport setup", tone: loginAccent)
|
||||
|
||||
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("Turn this device into a passport for your idp.global identity")
|
||||
.font(.system(size: compactLayout ? 28 : 36, weight: .bold, design: .rounded))
|
||||
.lineLimit(3)
|
||||
|
||||
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))
|
||||
Text("Scan a linking QR code or paste a payload to activate this device as your passport for identity proofs and security alerts.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview pairing payload…")
|
||||
.tint(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
LoginFeatureRow(icon: "qrcode.viewfinder", title: "Scan a QR code from the web flow")
|
||||
LoginFeatureRow(icon: "doc.text.viewfinder", title: "Paste a payload when you already have one")
|
||||
LoginFeatureRow(icon: "iphone.gen3", title: "Handle identity checks and alerts here")
|
||||
}
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview passport...")
|
||||
.tint(loginAccent)
|
||||
}
|
||||
.padding(compactLayout ? 22 : 32)
|
||||
}
|
||||
.frame(minHeight: compactLayout ? 280 : 320)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginFeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(loginAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,46 +92,41 @@ private struct PairingConsoleCard: View {
|
||||
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.")
|
||||
AppSectionCard(title: "Set up passport", compactLayout: compactLayout) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Link payload")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
AppTextEditorField(
|
||||
text: $model.manualPairingPayload,
|
||||
minHeight: compactLayout ? 132 : 150
|
||||
)
|
||||
}
|
||||
|
||||
if model.isAuthenticating {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Activating this passport...")
|
||||
.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))
|
||||
Text("NFC, QR, and OTP proof methods become available after this passport is active.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if model.isAuthenticating {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Binding this device to your account…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
|
||||
Group {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,154 +137,57 @@ private struct PairingConsoleCard: View {
|
||||
Button {
|
||||
model.isScannerPresented = true
|
||||
} label: {
|
||||
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
|
||||
Label("Scan QR", systemImage: "qrcode.viewfinder")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.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)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var secondaryButtons: some View {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
usePayloadButton
|
||||
previewPayloadButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
usePayloadButton
|
||||
previewPayloadButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var usePayloadButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedCode()
|
||||
await model.signInWithManualPayload()
|
||||
}
|
||||
} label: {
|
||||
Label("Use Preview QR", systemImage: "wand.and.stars")
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Link with payload", systemImage: "arrow.right.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isAuthenticating)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
private var previewPayloadButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
Label("Use preview passport", systemImage: "wand.and.stars")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
296
Sources/Features/Auth/NFCPairingView.swift
Normal file
@@ -0,0 +1,296 @@
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(CoreLocation) && canImport(CoreNFC) && canImport(CryptoKit) && os(iOS)
|
||||
import CoreLocation
|
||||
import CoreNFC
|
||||
import CryptoKit
|
||||
|
||||
@MainActor
|
||||
final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCNDEFReaderSessionDelegate {
|
||||
@Published private(set) var helperText: String
|
||||
@Published private(set) var isScanning = false
|
||||
@Published private(set) var isSupported: Bool
|
||||
|
||||
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
private let signedGPSPositionProvider = SignedGPSPositionProvider()
|
||||
private var session: NFCNDEFReaderSession?
|
||||
private var isPreparingLocationProof = false
|
||||
|
||||
override init() {
|
||||
let supported = NFCNDEFReaderSession.readingAvailable
|
||||
_helperText = Published(initialValue: supported ? NFCIdentifyReader.idleHelperText : NFCIdentifyReader.unavailableHelperText)
|
||||
_isSupported = Published(initialValue: supported)
|
||||
super.init()
|
||||
}
|
||||
|
||||
func beginScanning() {
|
||||
refreshAvailability()
|
||||
|
||||
guard isSupported else {
|
||||
onError?(Self.unavailableErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
guard !isScanning else { return }
|
||||
|
||||
isScanning = true
|
||||
isPreparingLocationProof = false
|
||||
helperText = Self.scanningHelperText
|
||||
|
||||
let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
|
||||
session.alertMessage = "Hold your iPhone near the idp.global tag. A signed GPS position will be attached to this NFC identify action."
|
||||
self.session = session
|
||||
session.begin()
|
||||
}
|
||||
|
||||
func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.helperText = Self.scanningHelperText
|
||||
}
|
||||
}
|
||||
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
||||
guard let payload = extractPayload(from: messages) else {
|
||||
session.invalidate()
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
self.onError?(Self.invalidTagMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isPreparingLocationProof = true
|
||||
self.helperText = Self.signingLocationHelperText
|
||||
|
||||
Task { @MainActor in
|
||||
await self.completeAuthentication(for: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||
let nsError = error as NSError
|
||||
let ignoredCodes = [200, 204] // User canceled, first tag read.
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.session = nil
|
||||
}
|
||||
|
||||
guard !(nsError.domain == NFCErrorDomain && ignoredCodes.contains(nsError.code)) else {
|
||||
if !isPreparingLocationProof {
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
self.onError?(Self.failureMessage(for: nsError))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func completeAuthentication(for payload: String) async {
|
||||
do {
|
||||
let signedGPSPosition = try await signedGPSPositionProvider.currentSignedGPSPosition(for: payload)
|
||||
let request = PairingAuthenticationRequest(
|
||||
pairingPayload: payload,
|
||||
transport: .nfc,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
finishScanning()
|
||||
onAuthenticationRequestDetected?(request)
|
||||
} catch let error as AppError {
|
||||
finishScanning()
|
||||
onError?(error.errorDescription ?? Self.gpsSigningFailureMessage)
|
||||
} catch {
|
||||
finishScanning()
|
||||
onError?(Self.gpsSigningFailureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishScanning() {
|
||||
session = nil
|
||||
isPreparingLocationProof = false
|
||||
isScanning = false
|
||||
refreshAvailability()
|
||||
}
|
||||
|
||||
private func refreshAvailability() {
|
||||
let available = NFCNDEFReaderSession.readingAvailable
|
||||
isSupported = available
|
||||
if !isScanning {
|
||||
helperText = available ? Self.idleHelperText : Self.unavailableHelperText
|
||||
}
|
||||
}
|
||||
|
||||
private func extractPayload(from messages: [NFCNDEFMessage]) -> String? {
|
||||
for message in messages {
|
||||
for record in message.records {
|
||||
if let url = record.wellKnownTypeURIPayload() {
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
let (text, _) = record.wellKnownTypeTextPayload()
|
||||
if let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if let fallback = String(data: record.payload, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!fallback.isEmpty {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func failureMessage(for error: NSError) -> String {
|
||||
if error.domain == NFCErrorDomain && error.code == 2 {
|
||||
return "NFC identify is not permitted in this build. Check the NFC entitlement and privacy description."
|
||||
}
|
||||
|
||||
return "NFC identify could not be completed on this device."
|
||||
}
|
||||
|
||||
private static let idleHelperText = "Tap to identify with an NFC tag on supported iPhone hardware. A signed GPS position will be attached automatically."
|
||||
private static let scanningHelperText = "Hold the top of your iPhone near the NFC tag until it is identified."
|
||||
private static let signingLocationHelperText = "Tag detected. Capturing and signing the current GPS position for NFC identify."
|
||||
private static let unavailableHelperText = "NFC identify is unavailable on this device."
|
||||
private static let unavailableErrorMessage = "Tap to identify requires supported iPhone hardware with NFC enabled."
|
||||
private static let invalidTagMessage = "The NFC tag did not contain a usable idp.global payload."
|
||||
private static let gpsSigningFailureMessage = "The NFC tag was read, but the signed GPS position could not be attached."
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SignedGPSPositionProvider: NSObject, @preconcurrency CLLocationManagerDelegate {
|
||||
private var manager: CLLocationManager?
|
||||
private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
|
||||
|
||||
func currentSignedGPSPosition(for pairingPayload: String) async throws -> SignedGPSPosition {
|
||||
let location = try await currentLocation()
|
||||
return try sign(location: location, pairingPayload: pairingPayload)
|
||||
}
|
||||
|
||||
private func currentLocation() async throws -> CLLocation {
|
||||
let manager = CLLocationManager()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
manager.distanceFilter = kCLDistanceFilterNone
|
||||
self.manager = manager
|
||||
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
break
|
||||
case .notDetermined:
|
||||
let status = await requestAuthorization(using: manager)
|
||||
guard status == .authorizedAlways || status == .authorizedWhenInUse else {
|
||||
throw AppError.locationPermissionDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw AppError.locationPermissionDenied
|
||||
@unknown default:
|
||||
throw AppError.locationUnavailable
|
||||
}
|
||||
|
||||
return try await requestLocation(using: manager)
|
||||
}
|
||||
|
||||
private func requestAuthorization(using manager: CLLocationManager) async -> CLAuthorizationStatus {
|
||||
manager.requestWhenInUseAuthorization()
|
||||
return await withCheckedContinuation { continuation in
|
||||
authorizationContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation(using manager: CLLocationManager) async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
locationContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func sign(location: CLLocation, pairingPayload: String) throws -> SignedGPSPosition {
|
||||
let isFresh = abs(location.timestamp.timeIntervalSinceNow) <= 120
|
||||
guard location.horizontalAccuracy >= 0,
|
||||
isFresh else {
|
||||
throw AppError.locationUnavailable
|
||||
}
|
||||
|
||||
let unsignedPosition = SignedGPSPosition(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude,
|
||||
horizontalAccuracyMeters: location.horizontalAccuracy,
|
||||
capturedAt: location.timestamp
|
||||
)
|
||||
|
||||
let privateKey = P256.Signing.PrivateKey()
|
||||
let signature = try privateKey.signature(for: unsignedPosition.signingPayload(for: pairingPayload))
|
||||
return unsignedPosition.signed(
|
||||
signatureData: signature.derRepresentation,
|
||||
publicKeyData: privateKey.publicKey.x963Representation
|
||||
)
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
guard let continuation = authorizationContinuation else { return }
|
||||
|
||||
let status = manager.authorizationStatus
|
||||
guard status != .notDetermined else { return }
|
||||
|
||||
authorizationContinuation = nil
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let continuation = locationContinuation,
|
||||
let location = locations.last else {
|
||||
return
|
||||
}
|
||||
|
||||
authorizationContinuation = nil
|
||||
locationContinuation = nil
|
||||
self.manager = nil
|
||||
continuation.resume(returning: location)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
|
||||
authorizationContinuation = nil
|
||||
locationContinuation = nil
|
||||
self.manager = nil
|
||||
|
||||
if let locationError = error as? CLError,
|
||||
locationError.code == .denied {
|
||||
continuation.resume(throwing: AppError.locationPermissionDenied)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(throwing: AppError.locationUnavailable)
|
||||
}
|
||||
}
|
||||
#else
|
||||
@MainActor
|
||||
final class NFCIdentifyReader: NSObject, ObservableObject {
|
||||
@Published private(set) var helperText = "NFC identify with a signed GPS position is available on supported iPhone hardware only."
|
||||
@Published private(set) var isScanning = false
|
||||
@Published private(set) var isSupported = false
|
||||
|
||||
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
func beginScanning() {
|
||||
onError?("Tap to identify requires supported iPhone hardware with NFC and location access enabled.")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -9,56 +9,58 @@ import AppKit
|
||||
|
||||
struct QRScannerSheet: View {
|
||||
let seededPayload: String
|
||||
let title: String
|
||||
let description: String
|
||||
let navigationTitleText: String
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var manualFallback = ""
|
||||
|
||||
init(
|
||||
seededPayload: String,
|
||||
title: String = "Scan QR",
|
||||
description: String = "Use the camera to scan an idp.global QR challenge.",
|
||||
navigationTitle: String = "Scan QR",
|
||||
onCodeScanned: @escaping (String) -> Void
|
||||
) {
|
||||
self.seededPayload = seededPayload
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.navigationTitleText = navigationTitle
|
||||
self.onCodeScanned = onCodeScanned
|
||||
}
|
||||
|
||||
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.")
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppSectionCard(title: title, compactLayout: compactLayout) {
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.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))
|
||||
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
|
||||
AppTextEditorField(text: $manualFallback, minHeight: 120)
|
||||
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationTitle(navigationTitleText)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
@@ -71,6 +73,36 @@ struct QRScannerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var useFallbackButton: some View {
|
||||
Button {
|
||||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Use payload", systemImage: "arrow.up.forward.square")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
private var useSeededButton: some View {
|
||||
Button {
|
||||
manualFallback = seededPayload
|
||||
} label: {
|
||||
Label("Reset sample", systemImage: "wand.and.rays")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveQRScannerView: View {
|
||||
|
||||
33
WatchApp/App/IDPGlobalWatchApp.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IDPGlobalWatchApp: App {
|
||||
@StateObject private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView(model: model)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
.alert("Something went wrong", isPresented: errorPresented) {
|
||||
Button("OK") {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var errorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
479
WatchApp/Features/WatchRootView.swift
Normal file
@@ -0,0 +1,479 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let watchAccent = AppTheme.accent
|
||||
private let watchGold = AppTheme.warmAccent
|
||||
|
||||
struct WatchRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if model.session == nil {
|
||||
WatchPairingView(model: model)
|
||||
} else {
|
||||
WatchDashboardView(model: model)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.tint(watchAccent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchPairingView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AppPanel(compactLayout: true, radius: 22) {
|
||||
AppBadge(title: "Preview passport", tone: watchAccent)
|
||||
|
||||
Text("Prove identity from your wrist")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
Text("This preview connects directly to the mock service today.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
|
||||
AppStatusTag(title: "Preview sync", tone: watchGold)
|
||||
}
|
||||
}
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview passport...")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Use Preview Passport", systemImage: "qrcode")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
|
||||
|
||||
AppPanel(compactLayout: true, radius: 18) {
|
||||
Text("What works today")
|
||||
.font(.headline)
|
||||
|
||||
Text("The watch shows pending identity checks, recent alerts, and quick actions.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationTitle("Set Up Watch")
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchInfoPill: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let tone: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tone.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchDashboardView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
WatchPassportCard(model: model)
|
||||
}
|
||||
|
||||
Section("Pending") {
|
||||
if model.pendingRequests.isEmpty {
|
||||
Text("No checks waiting.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button("Seed Identity Check") {
|
||||
Task {
|
||||
await model.simulateIncomingRequest()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(model.pendingRequests) { request in
|
||||
NavigationLink {
|
||||
WatchRequestDetailView(model: model, requestID: request.id)
|
||||
} label: {
|
||||
WatchRequestRow(request: request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Recent Activity") {
|
||||
if model.notifications.isEmpty {
|
||||
Text("No recent alerts.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(model.notifications.prefix(3)) { notification in
|
||||
NavigationLink {
|
||||
WatchNotificationDetailView(model: model, notificationID: notification.id)
|
||||
} label: {
|
||||
WatchNotificationRow(notification: notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Actions") {
|
||||
Button("Refresh") {
|
||||
Task {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
.disabled(model.isRefreshing)
|
||||
|
||||
Button("Send Test Alert") {
|
||||
Task {
|
||||
await model.sendTestNotification()
|
||||
}
|
||||
}
|
||||
|
||||
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
|
||||
Button("Enable Alerts") {
|
||||
Task {
|
||||
await model.requestNotificationAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Account") {
|
||||
if let profile = model.profile {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(profile.handle)
|
||||
.font(.headline)
|
||||
Text(profile.organization)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Notifications")
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.title)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button("Sign Out", role: .destructive) {
|
||||
model.signOut()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Passport")
|
||||
.refreshable {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchPassportCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.profile?.name ?? "Preview Session")
|
||||
.font(.headline)
|
||||
Text(model.pairedDeviceSummary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let session = model.session {
|
||||
Text("Via \(session.pairingTransport.title)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
WatchMetricPill(title: "Pending", value: "\(model.pendingRequests.count)", accent: watchAccent)
|
||||
WatchMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)", accent: watchGold)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchMetricPill: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let accent: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(value)
|
||||
.font(.headline.monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchRequestRow: View {
|
||||
let request: ApprovalRequest
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(request.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
Image(systemName: request.risk == .elevated ? "exclamationmark.shield.fill" : "checkmark.shield.fill")
|
||||
.foregroundStyle(request.risk == .elevated ? .orange : watchAccent)
|
||||
}
|
||||
|
||||
Text(request.source)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(request.createdAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchNotificationRow: View {
|
||||
let notification: AppNotification
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if notification.isUnread {
|
||||
Circle()
|
||||
.fill(watchAccent)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(notification.sentAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchRequestDetailView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let requestID: ApprovalRequest.ID
|
||||
|
||||
private var request: ApprovalRequest? {
|
||||
model.requests.first(where: { $0.id == requestID })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let request {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
detailHeader(
|
||||
title: request.title,
|
||||
subtitle: request.source,
|
||||
badge: request.status.title
|
||||
)
|
||||
|
||||
Text(request.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Trust Summary")
|
||||
.font(.headline)
|
||||
Text(request.trustHeadline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(request.trustDetail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(request.risk.guidance)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
|
||||
if !request.scopes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Scopes")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(request.scopes, id: \.self) { scope in
|
||||
Label(scope, systemImage: "checkmark.seal.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if request.status == .pending {
|
||||
if model.activeRequestID == request.id {
|
||||
ProgressView("Updating proof...")
|
||||
} else {
|
||||
Button("Verify") {
|
||||
Task {
|
||||
await model.approve(request)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Decline", role: .destructive) {
|
||||
Task {
|
||||
await model.reject(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
} else {
|
||||
Text("This request is no longer available.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Identity Check")
|
||||
}
|
||||
|
||||
private func detailHeader(title: String, subtitle: String, badge: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(badge)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(watchAccent.opacity(0.14), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchNotificationDetailView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let notificationID: AppNotification.ID
|
||||
|
||||
private var notification: AppNotification? {
|
||||
model.notifications.first(where: { $0.id == notificationID })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let notification {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
Text(notification.kind.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(watchAccent)
|
||||
Text(notification.sentAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Alert posture")
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.summary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
|
||||
if notification.isUnread {
|
||||
Button("Mark Read") {
|
||||
Task {
|
||||
await model.markNotificationRead(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
} else {
|
||||
Text("This activity item has already been cleared.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var watchRelativeString: String {
|
||||
WatchFormatters.relative.localizedString(for: self, relativeTo: .now)
|
||||
}
|
||||
}
|
||||
|
||||
private enum WatchFormatters {
|
||||
static let relative: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||