feat(account): add QR invitation poster and 8-character invitation codes

- Invite opens an appearance-matched poster with the app icon, inviter name,
  QR code, and short code; tap for a full-brightness QR view; save (add-only
  Photos access), share, or copy the link. Mac shows the poster in a popover
  and can save it as PNG.
- Accounts get look-alike-free 8-character codes. "I have a code" accepts a
  code, link, or pasted message during the server-defined binding window.
- Permanently rejected redemptions show a specific message and drop the
  stored code instead of retrying on every sign-in.
This commit is contained in:
2026-10-20 20:06:36 +02:00
parent eaacc35cf7
commit 03bfbbe08b
26 changed files with 2269 additions and 86 deletions
@@ -37,6 +37,59 @@ final class ReferralProfileTests: XCTestCase {
XCTAssertEqual(profile.code.inviteURL.absoluteString, inviteURL)
XCTAssertNil(profile.code.campaignID)
XCTAssertNotNil(profile.binding)
XCTAssertNil(profile.code.shortCode)
XCTAssertNil(profile.bindingDeadline)
}
func testReferralProfileDecodesShortCodeAndBindingDeadlineAndSurvivesCache() throws {
let data = Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
"createdAt": "2026-08-20T12:34:56Z",
"shortCode": "K7M2Q9XA"
},
"bindingDeadline": "2026-08-27T12:34:56Z"
}
""".utf8
)
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
let cached = try JSONDecoder().decode(
ReferralProfile.self,
from: JSONEncoder().encode(profile)
)
XCTAssertEqual(profile.code.shortCode, "K7M2Q9XA")
XCTAssertEqual(profile.code.displayShortCode, "K7M2-Q9XA")
XCTAssertEqual(
profile.bindingDeadline,
ISO8601DateFormatter().date(from: "2026-08-27T12:34:56Z")
)
XCTAssertEqual(cached, profile)
}
func testMalformedShortCodeIsDroppedWithoutRejectingTheProfile() throws {
let data = Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
"createdAt": "2026-08-20T12:34:56Z",
"shortCode": "0OIL0OIL"
},
"bindingDeadline": "not a date"
}
""".utf8
)
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
XCTAssertNil(profile.code.shortCode)
XCTAssertNil(profile.bindingDeadline)
}
func testLiveReferralServiceUsesAuthenticatedGETWithBoundedTimeout() async throws {